about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/cache/profile-shadow.ts24
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx22
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx34
-rw-r--r--src/view/com/profile/ProfileHeader.tsx123
-rw-r--r--src/view/com/util/UserAvatar.tsx5
-rw-r--r--src/view/screens/Profile.tsx68
6 files changed, 141 insertions, 135 deletions
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 79a1f228e..34fe5995d 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -22,15 +22,15 @@ export interface ProfileShadow {
   blockingUri: string | undefined
 }
 
-type ProfileView =
-  | AppBskyActorDefs.ProfileView
-  | AppBskyActorDefs.ProfileViewBasic
-  | AppBskyActorDefs.ProfileViewDetailed
-
-const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap()
+const shadows: WeakMap<
+  AppBskyActorDefs.ProfileView,
+  Partial<ProfileShadow>
+> = new WeakMap()
 const emitter = new EventEmitter()
 
-export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> {
+export function useProfileShadow<
+  TProfileView extends AppBskyActorDefs.ProfileView,
+>(profile: TProfileView): Shadow<TProfileView> {
   const [shadow, setShadow] = useState(() => shadows.get(profile))
   const [prevPost, setPrevPost] = useState(profile)
   if (profile !== prevPost) {
@@ -70,10 +70,10 @@ export function updateProfileShadow(
   })
 }
 
-function mergeShadow(
-  profile: ProfileView,
+function mergeShadow<TProfileView extends AppBskyActorDefs.ProfileView>(
+  profile: TProfileView,
   shadow: Partial<ProfileShadow>,
-): Shadow<ProfileView> {
+): Shadow<TProfileView> {
   return castAsShadow({
     ...profile,
     viewer: {
@@ -89,7 +89,9 @@ function mergeShadow(
   })
 }
 
-function* findProfilesInCache(did: string): Generator<ProfileView, void> {
+function* findProfilesInCache(
+  did: string,
+): Generator<AppBskyActorDefs.ProfileView, void> {
   yield* findAllProfilesInListMembersQueryData(queryClient, did)
   yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did)
   yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 7e9ed24db..31abc1ab7 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -61,25 +61,21 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
     const headerHeight = headerOnlyHeight + tabBarHeight
 
     // capture the header bar sizing
-    const onTabBarLayout = React.useCallback(
-      (evt: LayoutChangeEvent) => {
-        const height = evt.nativeEvent.layout.height
-        if (height > 0) {
-          // The rounding is necessary to prevent jumps on iOS
-          setTabBarHeight(Math.round(height))
-        }
-      },
-      [setTabBarHeight],
-    )
-    const onHeaderOnlyLayout = React.useCallback(
+    const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
+      const height = evt.nativeEvent.layout.height
+      if (height > 0) {
+        // The rounding is necessary to prevent jumps on iOS
+        setTabBarHeight(Math.round(height))
+      }
+    })
+    const onHeaderOnlyLayout = useNonReactiveCallback(
       (evt: LayoutChangeEvent) => {
         const height = evt.nativeEvent.layout.height
-        if (height > 0) {
+        if (height > 0 && isHeaderReady) {
           // The rounding is necessary to prevent jumps on iOS
           setHeaderOnlyHeight(Math.round(height))
         }
       },
-      [setHeaderOnlyHeight],
     )
 
     const renderTabBar = React.useCallback(
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
index 4f959d548..9c63c149f 100644
--- a/src/view/com/pager/PagerWithHeader.web.tsx
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -31,6 +31,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
       children,
       testID,
       items,
+      isHeaderReady,
       renderHeader,
       initialPage,
       onPageSelected,
@@ -46,6 +47,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
           <PagerTabBar
             items={items}
             renderHeader={renderHeader}
+            isHeaderReady={isHeaderReady}
             currentPage={currentPage}
             onCurrentPageSelected={onCurrentPageSelected}
             onSelect={props.onSelect}
@@ -54,7 +56,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
           />
         )
       },
-      [items, renderHeader, currentPage, onCurrentPageSelected, testID],
+      [
+        items,
+        isHeaderReady,
+        renderHeader,
+        currentPage,
+        onCurrentPageSelected,
+        testID,
+      ],
     )
 
     const onPageSelectedInner = React.useCallback(
@@ -80,8 +89,14 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
         {toArray(children)
           .filter(Boolean)
           .map((child, i) => {
+            const isReady = isHeaderReady
             return (
-              <View key={i} collapsable={false}>
+              <View
+                key={i}
+                collapsable={false}
+                style={{
+                  display: isReady ? undefined : 'none',
+                }}>
                 <PagerItem isFocused={i === currentPage} renderTab={child} />
               </View>
             )
@@ -94,6 +109,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
 let PagerTabBar = ({
   currentPage,
   items,
+  isHeaderReady,
   testID,
   renderHeader,
   onCurrentPageSelected,
@@ -104,6 +120,7 @@ let PagerTabBar = ({
   items: string[]
   testID?: string
   renderHeader?: () => JSX.Element
+  isHeaderReady: boolean
   onCurrentPageSelected?: (index: number) => void
   onSelect?: (index: number) => void
   tabBarAnchor?: JSX.Element | null | undefined
@@ -112,7 +129,12 @@ let PagerTabBar = ({
   const {isMobile} = useWebMediaQueries()
   return (
     <>
-      <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
+      <View
+        style={[
+          !isMobile && styles.headerContainerDesktop,
+          pal.border,
+          !isHeaderReady && styles.loadingHeader,
+        ]}>
         {renderHeader?.()}
       </View>
       {tabBarAnchor}
@@ -123,6 +145,9 @@ let PagerTabBar = ({
             ? styles.tabBarContainerMobile
             : styles.tabBarContainerDesktop,
           pal.border,
+          {
+            display: isHeaderReady ? undefined : 'none',
+          },
         ]}>
         <TabBar
           testID={testID}
@@ -183,6 +208,9 @@ const styles = StyleSheet.create({
     paddingLeft: 14,
     paddingRight: 14,
   },
+  loadingHeader: {
+    borderColor: 'transparent',
+  },
 })
 
 function toArray<T>(v: T | T[]): T[] {
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 2e80ca808..8fd50fad6 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -51,76 +51,47 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {shareUrl} from 'lib/sharing'
 import {s, colors} from 'lib/styles'
 import {logger} from '#/logger'
-import {useSession, getAgent} from '#/state/session'
+import {useSession} 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: AppBskyActorDefs.ProfileView | null
-  placeholderData?: AppBskyActorDefs.ProfileView | null
-  moderationOpts: ModerationOpts | null
-  hideBackButton?: boolean
-  isProfilePreview?: boolean
-}
-
-export function ProfileHeader({
-  profile,
-  moderationOpts,
-  hideBackButton = false,
-  isProfilePreview,
-}: Props) {
+let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
-
-  // loading
-  // =
-  if (!profile || !moderationOpts) {
-    return (
-      <View style={pal.view}>
-        <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} />
-        </View>
-        <View style={styles.content}>
-          <View style={[styles.buttonsLine]}>
-            <LoadingPlaceholder width={167} height={31} style={styles.br50} />
-          </View>
+  return (
+    <View style={pal.view}>
+      <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} />
+      </View>
+      <View style={styles.content}>
+        <View style={[styles.buttonsLine]}>
+          <LoadingPlaceholder width={167} height={31} style={styles.br50} />
         </View>
       </View>
-    )
-  }
-
-  // loaded
-  // =
-  return (
-    <ProfileHeaderLoaded
-      profile={profile}
-      moderationOpts={moderationOpts}
-      hideBackButton={hideBackButton}
-      isProfilePreview={isProfilePreview}
-    />
+    </View>
   )
 }
+ProfileHeaderLoading = memo(ProfileHeaderLoading)
+export {ProfileHeaderLoading}
 
-interface LoadedProps {
+interface Props {
   profile: AppBskyActorDefs.ProfileViewDetailed
+  descriptionRT: RichTextAPI | null
   moderationOpts: ModerationOpts
   hideBackButton?: boolean
-  isProfilePreview?: boolean
+  isPlaceholderProfile?: boolean
 }
 
-let ProfileHeaderLoaded = ({
+let ProfileHeader = ({
   profile: profileUnshadowed,
+  descriptionRT,
   moderationOpts,
   hideBackButton = false,
-  isProfilePreview,
-}: LoadedProps): React.ReactNode => {
+  isPlaceholderProfile,
+}: Props): React.ReactNode => {
   const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
     useProfileShadow(profileUnshadowed)
   const pal = usePalette('default')
@@ -144,37 +115,6 @@ let ProfileHeaderLoaded = ({
     [profile, moderationOpts],
   )
 
-  /*
-   * BEGIN handle bio facet resolution
-   */
-  // should be undefined on first render to trigger a resolution
-  const prevProfileDescription = React.useRef<string | undefined>()
-  const [descriptionRT, setDescriptionRT] = React.useState<
-    RichTextAPI | undefined
-  >(
-    profile.description
-      ? new RichTextAPI({text: profile.description})
-      : undefined,
-  )
-  React.useEffect(() => {
-    async function resolveRTFacets() {
-      // new each time
-      const rt = new RichTextAPI({text: profile.description || ''})
-      await rt.detectFacets(getAgent())
-      // replace existing RT instance
-      setDescriptionRT(rt)
-    }
-
-    if (profile.description !== prevProfileDescription.current) {
-      // update prev immediately
-      prevProfileDescription.current = profile.description
-      resolveRTFacets()
-    }
-  }, [profile.description, setDescriptionRT])
-  /*
-   * END handle bio facet resolution
-   */
-
   const invalidateProfileQuery = React.useCallback(() => {
     queryClient.invalidateQueries({
       queryKey: profileQueryKey(profile.did),
@@ -454,14 +394,9 @@ let ProfileHeaderLoaded = ({
   const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
 
   return (
-    <View
-      style={[
-        pal.view,
-        isProfilePreview && isDesktop && styles.loadingBorderStyle,
-      ]}
-      pointerEvents="box-none">
+    <View style={[pal.view]} pointerEvents="box-none">
       <View pointerEvents="none">
-        {isProfilePreview ? (
+        {isPlaceholderProfile ? (
           <LoadingPlaceholder
             width="100%"
             height={150}
@@ -622,7 +557,7 @@ let ProfileHeaderLoaded = ({
             {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
           </ThemedText>
         </View>
-        {!isProfilePreview && !blockHide && (
+        {!isPlaceholderProfile && !blockHide && (
           <>
             <View style={styles.metricsLine} pointerEvents="box-none">
               <Link
@@ -737,7 +672,8 @@ let ProfileHeaderLoaded = ({
     </View>
   )
 }
-ProfileHeaderLoaded = memo(ProfileHeaderLoaded)
+ProfileHeader = memo(ProfileHeader)
+export {ProfileHeader}
 
 const styles = StyleSheet.create({
   banner: {
@@ -845,9 +781,4 @@ const styles = StyleSheet.create({
 
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
-
-  loadingBorderStyle: {
-    borderLeftWidth: 1,
-    borderRightWidth: 1,
-  },
 })
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 00ff7e1ec..f673db1ee 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -123,6 +123,7 @@ let UserAvatar = ({
   usePlainRNImage = false,
 }: UserAvatarProps): React.ReactNode => {
   const pal = usePalette('default')
+  const backgroundColor = pal.colors.backgroundLight
 
   const aviStyle = useMemo(() => {
     if (type === 'algo' || type === 'list') {
@@ -130,14 +131,16 @@ let UserAvatar = ({
         width: size,
         height: size,
         borderRadius: size > 32 ? 8 : 3,
+        backgroundColor,
       }
     }
     return {
       width: size,
       height: size,
       borderRadius: Math.floor(size / 2),
+      backgroundColor,
     }
-  }, [type, size])
+  }, [type, size, backgroundColor])
 
   const alert = useMemo(() => {
     if (!moderation?.alert) {
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 9ca1b8c05..64e067593 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,7 +1,12 @@
 import React, {useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
-import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ModerationOpts,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
@@ -11,7 +16,7 @@ import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {Feed} from 'view/com/posts/Feed'
 import {ProfileLists} from '../com/lists/ProfileLists'
 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
-import {ProfileHeader} from '../com/profile/ProfileHeader'
+import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
 import {EmptyState} from '../com/util/EmptyState'
@@ -28,7 +33,7 @@ import {
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {useSession} from '#/state/session'
+import {useSession, getAgent} 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'
@@ -87,14 +92,10 @@ export function ProfileScreen({route}: Props) {
   }, [profile?.viewer?.blockedBy, resolvedDid])
 
   // Most pushes will happen here, since we will have only placeholder data
-  if (isLoadingDid || isLoadingProfile || isPlaceholderProfile) {
+  if (isLoadingDid || isLoadingProfile) {
     return (
       <CenteredView>
-        <ProfileHeader
-          profile={profile ?? null}
-          moderationOpts={moderationOpts ?? null}
-          isProfilePreview={true}
-        />
+        <ProfileHeaderLoading />
       </CenteredView>
     )
   }
@@ -114,6 +115,7 @@ export function ProfileScreen({route}: Props) {
       <ProfileScreenLoaded
         profile={profile}
         moderationOpts={moderationOpts}
+        isPlaceholderProfile={isPlaceholderProfile}
         hideBackButton={!!route.params.hideBackButton}
       />
     )
@@ -132,12 +134,14 @@ export function ProfileScreen({route}: Props) {
 
 function ProfileScreenLoaded({
   profile: profileUnshadowed,
+  isPlaceholderProfile,
   moderationOpts,
   hideBackButton,
 }: {
   profile: AppBskyActorDefs.ProfileViewDetailed
   moderationOpts: ModerationOpts
   hideBackButton: boolean
+  isPlaceholderProfile: boolean
 }) {
   const profile = useProfileShadow(profileUnshadowed)
   const {hasSession, currentAccount} = useSession()
@@ -157,6 +161,10 @@ function ProfileScreenLoaded({
 
   useSetTitle(combinedDisplayName(profile))
 
+  const description = profile.description ?? ''
+  const hasDescription = description !== ''
+  const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
+  const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
   const moderation = useMemo(
     () => moderateProfile(profile, moderationOpts),
     [profile, moderationOpts],
@@ -270,11 +278,20 @@ function ProfileScreenLoaded({
     return (
       <ProfileHeader
         profile={profile}
+        descriptionRT={hasDescription ? descriptionRT : null}
         moderationOpts={moderationOpts}
         hideBackButton={hideBackButton}
+        isPlaceholderProfile={showPlaceholder}
       />
     )
-  }, [profile, moderationOpts, hideBackButton])
+  }, [
+    profile,
+    descriptionRT,
+    hasDescription,
+    moderationOpts,
+    hideBackButton,
+    showPlaceholder,
+  ])
 
   return (
     <ScreenHider
@@ -284,7 +301,7 @@ function ProfileScreenLoaded({
       moderation={moderation.account}>
       <PagerWithHeader
         testID="profilePager"
-        isHeaderReady={true}
+        isHeaderReady={!showPlaceholder}
         items={sectionTitles}
         onPageSelected={onPageSelected}
         onCurrentPageSelected={onCurrentPageSelected}
@@ -441,6 +458,35 @@ function ProfileEndOfFeed() {
   )
 }
 
+function useRichText(text: string): [RichTextAPI, boolean] {
+  const [prevText, setPrevText] = React.useState(text)
+  const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
+  const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
+  if (text !== prevText) {
+    setPrevText(text)
+    setRawRT(new RichTextAPI({text}))
+    setResolvedRT(null)
+    // This will queue an immediate re-render
+  }
+  React.useEffect(() => {
+    let ignore = false
+    async function resolveRTFacets() {
+      // new each time
+      const resolvedRT = new RichTextAPI({text})
+      await resolvedRT.detectFacets(getAgent())
+      if (!ignore) {
+        setResolvedRT(resolvedRT)
+      }
+    }
+    resolveRTFacets()
+    return () => {
+      ignore = true
+    }
+  }, [text])
+  const isResolving = resolvedRT === null
+  return [resolvedRT ?? rawRT, isResolving]
+}
+
 const styles = StyleSheet.create({
   container: {
     flexDirection: 'column',