about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/state/modals/index.tsx3
-rw-r--r--src/state/models/ui/shell.ts5
-rw-r--r--src/state/queries/profile-extra-info.ts31
-rw-r--r--src/state/queries/profile.ts99
-rw-r--r--src/view/com/lightbox/Lightbox.tsx2
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx4
-rw-r--r--src/view/com/lists/MyLists.tsx (renamed from src/view/com/lists/ListsList.tsx)21
-rw-r--r--src/view/com/lists/ProfileLists.tsx197
-rw-r--r--src/view/com/modals/EditProfile.tsx74
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx4
-rw-r--r--src/view/com/profile/ProfileHeader.tsx2
-rw-r--r--src/view/screens/Lists.tsx4
-rw-r--r--src/view/screens/ModerationModlists.tsx4
-rw-r--r--src/view/screens/Profile.tsx66
14 files changed, 432 insertions, 84 deletions
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 6c63d9fc1..57f486630 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -3,7 +3,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
 import {StyleProp, ViewStyle, DeviceEventEmitter} from 'react-native'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
-import {ProfileModel} from '#/state/models/content/profile'
 import {ImageModel} from '#/state/models/media/image'
 import {GalleryModel} from '#/state/models/media/gallery'
 
@@ -20,7 +19,7 @@ export interface ConfirmModal {
 
 export interface EditProfileModal {
   name: 'edit-profile'
-  profileView: ProfileModel
+  profile: AppBskyActorDefs.ProfileViewDetailed
   onUpdate?: () => void
 }
 
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 8ef322db5..9ce9b6635 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,7 +1,6 @@
-import {AppBskyEmbedRecord} from '@atproto/api'
+import {AppBskyEmbedRecord, AppBskyActorDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
-import {ProfileModel} from '../content/profile'
 import {
   shouldRequestEmailConfirmation,
   setEmailConfirmationRequested,
@@ -18,7 +17,7 @@ interface LightboxModel {}
 
 export class ProfileImageLightbox implements LightboxModel {
   name = 'profile-image'
-  constructor(public profileView: ProfileModel) {
+  constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {
     makeAutoObservable(this)
   }
 }
diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts
new file mode 100644
index 000000000..54b19c89a
--- /dev/null
+++ b/src/state/queries/profile-extra-info.ts
@@ -0,0 +1,31 @@
+import {useQuery} from '@tanstack/react-query'
+import {useSession} from '../session'
+
+export const RQKEY = (did: string) => ['profile-extra-info', did]
+
+/**
+ * Fetches some additional information for the profile screen which
+ * is not available in the API's ProfileView
+ */
+export function useProfileExtraInfoQuery(did: string) {
+  const {agent} = useSession()
+  return useQuery({
+    queryKey: RQKEY(did),
+    async queryFn() {
+      const [listsRes, feedsRes] = await Promise.all([
+        agent.app.bsky.graph.getLists({
+          actor: did,
+          limit: 1,
+        }),
+        agent.app.bsky.feed.getActorFeeds({
+          actor: did,
+          limit: 1,
+        }),
+      ])
+      return {
+        hasLists: listsRes.data.lists.length > 0,
+        hasFeeds: feedsRes.data.feeds.length > 0,
+      }
+    },
+  })
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 1bd28d5b1..63367b261 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -1,14 +1,23 @@
-import {AtUri} from '@atproto/api'
-import {useQuery, useMutation} from '@tanstack/react-query'
+import {
+  AtUri,
+  AppBskyActorDefs,
+  AppBskyActorProfile,
+  AppBskyActorGetProfile,
+  BskyAgent,
+} from '@atproto/api'
+import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 import {useSession} from '../session'
 import {updateProfileShadow} from '../cache/profile-shadow'
+import {uploadBlob} from '#/lib/api'
+import {until} from '#/lib/async/until'
 
 export const RQKEY = (did: string) => ['profile', did]
 
 export function useProfileQuery({did}: {did: string | undefined}) {
   const {agent} = useSession()
   return useQuery({
-    queryKey: RQKEY(did),
+    queryKey: RQKEY(did || ''),
     queryFn: async () => {
       const res = await agent.getProfile({actor: did || ''})
       return res.data
@@ -17,6 +26,77 @@ export function useProfileQuery({did}: {did: string | undefined}) {
   })
 }
 
+interface ProfileUpdateParams {
+  profile: AppBskyActorDefs.ProfileView
+  updates: AppBskyActorProfile.Record
+  newUserAvatar: RNImage | undefined | null
+  newUserBanner: RNImage | undefined | null
+}
+export function useProfileUpdateMutation() {
+  const {agent} = useSession()
+  const queryClient = useQueryClient()
+  return useMutation<void, Error, ProfileUpdateParams>({
+    mutationFn: async ({profile, updates, newUserAvatar, newUserBanner}) => {
+      await agent.upsertProfile(async existing => {
+        existing = existing || {}
+        existing.displayName = updates.displayName
+        existing.description = updates.description
+        if (newUserAvatar) {
+          const res = await uploadBlob(
+            agent,
+            newUserAvatar.path,
+            newUserAvatar.mime,
+          )
+          existing.avatar = res.data.blob
+        } else if (newUserAvatar === null) {
+          existing.avatar = undefined
+        }
+        if (newUserBanner) {
+          const res = await uploadBlob(
+            agent,
+            newUserBanner.path,
+            newUserBanner.mime,
+          )
+          existing.banner = res.data.blob
+        } else if (newUserBanner === null) {
+          existing.banner = undefined
+        }
+        return existing
+      })
+      await whenAppViewReady(agent, profile.did, res => {
+        if (typeof newUserAvatar !== 'undefined') {
+          if (newUserAvatar === null && res.data.avatar) {
+            // url hasnt cleared yet
+            return false
+          } else if (res.data.avatar === profile.avatar) {
+            // url hasnt changed yet
+            return false
+          }
+        }
+        if (typeof newUserBanner !== 'undefined') {
+          if (newUserBanner === null && res.data.banner) {
+            // url hasnt cleared yet
+            return false
+          } else if (res.data.banner === profile.banner) {
+            // url hasnt changed yet
+            return false
+          }
+        }
+        return (
+          res.data.displayName === updates.displayName &&
+          res.data.description === updates.description
+        )
+      })
+    },
+    onSuccess(data, variables) {
+      // invalidate cache
+      queryClient.invalidateQueries({
+        queryKey: RQKEY(variables.profile.did),
+      })
+    },
+  })
+}
+
 export function useProfileFollowMutation() {
   const {agent} = useSession()
   return useMutation<{uri: string; cid: string}, Error, {did: string}>({
@@ -167,3 +247,16 @@ export function useProfileUnblockMutation() {
     },
   })
 }
+
+async function whenAppViewReady(
+  agent: BskyAgent,
+  actor: string,
+  fn: (res: AppBskyActorGetProfile.Response) => boolean,
+) {
+  await until(
+    5, // 5 tries
+    1e3, // 1s delay between tries
+    fn,
+    () => agent.app.bsky.actor.getProfile({actor}),
+  )
+}
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index 92c30f491..1b644fcea 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -25,7 +25,7 @@ export const Lightbox = observer(function Lightbox() {
     const opts = store.shell.activeLightbox as models.ProfileImageLightbox
     return (
       <ImageView
-        images={[{uri: opts.profileView.avatar || ''}]}
+        images={[{uri: opts.profile.avatar || ''}]}
         initialImageIndex={0}
         visible
         onRequestClose={onClose}
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index 331a2b823..4b6ad59f3 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -38,8 +38,8 @@ export const Lightbox = observer(function Lightbox() {
   let imgs: Img[] | undefined
   if (activeLightbox instanceof models.ProfileImageLightbox) {
     const opts = activeLightbox
-    if (opts.profileView.avatar) {
-      imgs = [{uri: opts.profileView.avatar}]
+    if (opts.profile.avatar) {
+      imgs = [{uri: opts.profile.avatar}]
     }
   } else if (activeLightbox instanceof models.ImagesLightbox) {
     const opts = activeLightbox
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx
index 100e0d609..2c080582e 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -12,7 +12,6 @@ import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {ListCard} from './ListCard'
 import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
 import {Text} from '../util/text/Text'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -25,9 +24,8 @@ import {cleanError} from '#/lib/strings/errors'
 const LOADING = {_reactKey: '__loading__'}
 const EMPTY = {_reactKey: '__empty__'}
 const ERROR_ITEM = {_reactKey: '__error__'}
-const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
 
-export function ListsList({
+export function MyLists({
   filter,
   inline,
   style,
@@ -42,7 +40,7 @@ export function ListsList({
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
-  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const [isPTRing, setIsPTRing] = React.useState(false)
   const {data, isFetching, isFetched, isError, error, refetch} =
     useMyListsQuery(filter)
   const isEmpty = !isFetching && !data?.length
@@ -67,14 +65,14 @@ export function ListsList({
 
   const onRefresh = React.useCallback(async () => {
     track('Lists:onRefresh')
-    setIsRefreshing(true)
+    setIsPTRing(true)
     try {
       await refetch()
     } catch (err) {
       logger.error('Failed to refresh lists', {error: err})
     }
-    setIsRefreshing(false)
-  }, [refetch, track, setIsRefreshing])
+    setIsPTRing(false)
+  }, [refetch, track, setIsPTRing])
 
   // rendering
   // =
@@ -98,13 +96,6 @@ export function ListsList({
             onPressTryAgain={onRefresh}
           />
         )
-      } else if (item === LOAD_MORE_ERROR_ITEM) {
-        return (
-          <LoadMoreRetryBtn
-            label="There was an issue fetching your lists. Tap here to try again."
-            onPress={onRefresh}
-          />
-        )
       } else if (item === LOADING) {
         return (
           <View style={{padding: 20}}>
@@ -136,7 +127,7 @@ export function ListsList({
           renderItem={renderItemInner}
           refreshControl={
             <RefreshControl
-              refreshing={isRefreshing}
+              refreshing={isPTRing}
               onRefresh={onRefresh}
               tintColor={pal.colors.text}
               titleColor={pal.colors.text}
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
new file mode 100644
index 000000000..a92af9f3c
--- /dev/null
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -0,0 +1,197 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  Dimensions,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {FlatList} from '../util/Views'
+import {ListCard} from './ListCard'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Text} from '../util/text/Text'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useProfileListsQuery} from '#/state/queries/profile-lists'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {logger} from '#/logger'
+import {Trans} from '@lingui/macro'
+import {cleanError} from '#/lib/strings/errors'
+import {useAnimatedScrollHandler} from 'react-native-reanimated'
+import {useTheme} from '#/lib/ThemeContext'
+
+const LOADING = {_reactKey: '__loading__'}
+const EMPTY = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export function ProfileLists({
+  did,
+  scrollElRef,
+  onScroll,
+  scrollEventThrottle,
+  headerOffset,
+  style,
+  testID,
+}: {
+  did: string
+  scrollElRef?: MutableRefObject<FlatList<any> | null>
+  onScroll?: OnScrollHandler
+  scrollEventThrottle?: number
+  headerOffset: number
+  style?: StyleProp<ViewStyle>
+  testID?: string
+}) {
+  const pal = usePalette('default')
+  const theme = useTheme()
+  const {track} = useAnalytics()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = useProfileListsQuery(did)
+  const isEmpty = !isFetching && !data?.pages[0]?.lists.length
+
+  const items = React.useMemo(() => {
+    let items: any[] = []
+    if (isError && isEmpty) {
+      items = items.concat([ERROR_ITEM])
+    }
+    if (!isFetched && isFetching) {
+      items = items.concat([LOADING])
+    } else if (isEmpty) {
+      items = items.concat([EMPTY])
+    } else if (data?.pages) {
+      for (const page of data?.pages) {
+        items = items.concat(page.lists)
+      }
+    }
+    if (isError && !isEmpty) {
+      items = items.concat([LOAD_MORE_ERROR_ITEM])
+    }
+    return items
+  }, [isError, isEmpty, isFetched, isFetching, data])
+
+  // events
+  // =
+
+  const onRefresh = React.useCallback(async () => {
+    track('Lists:onRefresh')
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh lists', {error: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, track, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+
+    track('Lists:onEndReached')
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more lists', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
+
+  const onPressRetryLoadMore = React.useCallback(() => {
+    fetchNextPage()
+  }, [fetchNextPage])
+
+  // rendering
+  // =
+
+  const renderItemInner = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === EMPTY) {
+        return (
+          <View
+            testID="listsEmpty"
+            style={[{padding: 18, borderTopWidth: 1}, pal.border]}>
+            <Text style={pal.textLight}>
+              <Trans>You have no lists.</Trans>
+            </Text>
+          </View>
+        )
+      } else if (item === ERROR_ITEM) {
+        return (
+          <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
+        )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching your lists. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
+      } else if (item === LOADING) {
+        return (
+          <View style={{padding: 20}}>
+            <ActivityIndicator />
+          </View>
+        )
+      }
+      return (
+        <ListCard
+          list={item}
+          testID={`list-${item.name}`}
+          style={styles.item}
+        />
+      )
+    },
+    [error, refetch, onPressRetryLoadMore, pal],
+  )
+
+  const scrollHandler = useAnimatedScrollHandler(onScroll || {})
+  return (
+    <View testID={testID} style={style}>
+      <FlatList
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={(item: any) => item._reactKey}
+        renderItem={renderItemInner}
+        refreshControl={
+          <RefreshControl
+            refreshing={isPTRing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+            progressViewOffset={headerOffset}
+          />
+        }
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
+        style={{paddingTop: headerOffset}}
+        onScroll={onScroll != null ? scrollHandler : undefined}
+        scrollEventThrottle={scrollEventThrottle}
+        indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'}
+        removeClippedSubviews={true}
+        contentOffset={{x: 0, y: headerOffset * -1}}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight
+        onEndReached={onEndReached}
+      />
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  item: {
+    paddingHorizontal: 18,
+    paddingVertical: 4,
+  },
+})
diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx
index eef408e98..e044f8c0e 100644
--- a/src/view/com/modals/EditProfile.tsx
+++ b/src/view/com/modals/EditProfile.tsx
@@ -11,9 +11,9 @@ import {
 } from 'react-native'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {ErrorMessage} from '../util/error/ErrorMessage'
-import {ProfileModel} from 'state/models/content/profile'
 import {s, colors, gradients} from 'lib/styles'
 import {enforceLen} from 'lib/strings/helpers'
 import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
@@ -23,12 +23,14 @@ import {EditableUserAvatar} from '../util/UserAvatar'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {useAnalytics} from 'lib/analytics/analytics'
-import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {cleanError} from 'lib/strings/errors'
 import Animated, {FadeOut} from 'react-native-reanimated'
 import {isWeb} from 'platform/detection'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
+import {useProfileUpdateMutation} from '#/state/queries/profile'
+import {logger} from '#/logger'
 
 const AnimatedTouchableOpacity =
   Animated.createAnimatedComponent(TouchableOpacity)
@@ -36,31 +38,30 @@ const AnimatedTouchableOpacity =
 export const snapPoints = ['fullscreen']
 
 export function Component({
-  profileView,
+  profile,
   onUpdate,
 }: {
-  profileView: ProfileModel
+  profile: AppBskyActorDefs.ProfileViewDetailed
   onUpdate?: () => void
 }) {
-  const [error, setError] = useState<string>('')
   const pal = usePalette('default')
   const theme = useTheme()
   const {track} = useAnalytics()
   const {_} = useLingui()
   const {closeModal} = useModalControls()
-
-  const [isProcessing, setProcessing] = useState<boolean>(false)
+  const updateMutation = useProfileUpdateMutation()
+  const [imageError, setImageError] = useState<string>('')
   const [displayName, setDisplayName] = useState<string>(
-    profileView.displayName || '',
+    profile.displayName || '',
   )
   const [description, setDescription] = useState<string>(
-    profileView.description || '',
+    profile.description || '',
   )
   const [userBanner, setUserBanner] = useState<string | undefined | null>(
-    profileView.banner,
+    profile.banner,
   )
   const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
-    profileView.avatar,
+    profile.avatar,
   )
   const [newUserBanner, setNewUserBanner] = useState<
     RNImage | undefined | null
@@ -73,6 +74,7 @@ export function Component({
   }
   const onSelectNewAvatar = useCallback(
     async (img: RNImage | null) => {
+      setImageError('')
       if (img === null) {
         setNewUserAvatar(null)
         setUserAvatar(null)
@@ -84,14 +86,15 @@ export function Component({
         setNewUserAvatar(finalImg)
         setUserAvatar(finalImg.path)
       } catch (e: any) {
-        setError(cleanError(e))
+        setImageError(cleanError(e))
       }
     },
-    [track, setNewUserAvatar, setUserAvatar, setError],
+    [track, setNewUserAvatar, setUserAvatar, setImageError],
   )
 
   const onSelectNewBanner = useCallback(
     async (img: RNImage | null) => {
+      setImageError('')
       if (!img) {
         setNewUserBanner(null)
         setUserBanner(null)
@@ -103,52 +106,42 @@ export function Component({
         setNewUserBanner(finalImg)
         setUserBanner(finalImg.path)
       } catch (e: any) {
-        setError(cleanError(e))
+        setImageError(cleanError(e))
       }
     },
-    [track, setNewUserBanner, setUserBanner, setError],
+    [track, setNewUserBanner, setUserBanner, setImageError],
   )
 
   const onPressSave = useCallback(async () => {
     track('EditProfile:Save')
-    setProcessing(true)
-    if (error) {
-      setError('')
-    }
+    setImageError('')
     try {
-      await profileView.updateProfile(
-        {
+      await updateMutation.mutateAsync({
+        profile,
+        updates: {
           displayName,
           description,
         },
         newUserAvatar,
         newUserBanner,
-      )
+      })
       Toast.show('Profile updated')
       onUpdate?.()
       closeModal()
     } catch (e: any) {
-      if (isNetworkError(e)) {
-        setError(
-          'Failed to save your profile. Check your internet connection and try again.',
-        )
-      } else {
-        setError(cleanError(e))
-      }
+      logger.error('Failed to update user profile', {error: String(e)})
     }
-    setProcessing(false)
   }, [
     track,
-    setProcessing,
-    setError,
-    error,
-    profileView,
+    updateMutation,
+    profile,
     onUpdate,
     closeModal,
     displayName,
     description,
     newUserAvatar,
     newUserBanner,
+    setImageError,
   ])
 
   return (
@@ -170,9 +163,14 @@ export function Component({
             />
           </View>
         </View>
-        {error !== '' && (
+        {updateMutation.isError && (
+          <View style={styles.errorContainer}>
+            <ErrorMessage message={cleanError(updateMutation.error)} />
+          </View>
+        )}
+        {imageError !== '' && (
           <View style={styles.errorContainer}>
-            <ErrorMessage message={error} />
+            <ErrorMessage message={imageError} />
           </View>
         )}
         <View style={styles.form}>
@@ -212,7 +210,7 @@ export function Component({
               accessibilityHint="Edit your profile description"
             />
           </View>
-          {isProcessing ? (
+          {updateMutation.isPending ? (
             <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
               <ActivityIndicator />
             </View>
@@ -235,7 +233,7 @@ export function Component({
               </LinearGradient>
             </TouchableOpacity>
           )}
-          {!isProcessing && (
+          {!updateMutation.isPending && (
             <AnimatedTouchableOpacity
               exiting={!isWeb ? FadeOut : undefined}
               testID="editProfileCancelBtn"
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index 73b1bc744..8c3dc8bb7 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -3,7 +3,7 @@ import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
-import {ListsList} from '../lists/ListsList'
+import {MyLists} from '../lists/MyLists'
 import {Button} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -51,7 +51,7 @@ export function Component({
       <Text style={[styles.title, pal.text]}>
         <Trans>Update {displayName} in Lists</Trans>
       </Text>
-      <ListsList
+      <MyLists
         filter="all"
         inline
         renderItem={(list, index) => (
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index ea3b86301..a228891a4 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -197,7 +197,7 @@ function ProfileHeaderLoaded({
     track('ProfileHeader:EditProfileButtonClicked')
     openModal({
       name: 'edit-profile',
-      profileView: profile,
+      profile,
     })
   }, [track, openModal, profile])
 
diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx
index 906fb5e5b..00711784d 100644
--- a/src/view/screens/Lists.tsx
+++ b/src/view/screens/Lists.tsx
@@ -5,7 +5,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ListsList} from 'view/com/lists/ListsList'
+import {MyLists} from '#/view/com/lists/MyLists'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
 import {NavigationProp} from 'lib/routes/types'
@@ -79,7 +79,7 @@ export const ListsScreen = withAuthRequired(
             </Button>
           </View>
         </SimpleViewHeader>
-        <ListsList filter="curate" style={s.flexGrow1} />
+        <MyLists filter="curate" style={s.flexGrow1} />
       </View>
     )
   },
diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx
index 098d93cdc..be0eb3850 100644
--- a/src/view/screens/ModerationModlists.tsx
+++ b/src/view/screens/ModerationModlists.tsx
@@ -5,7 +5,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {AtUri} from '@atproto/api'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ListsList} from 'view/com/lists/ListsList'
+import {MyLists} from '#/view/com/lists/MyLists'
 import {Text} from 'view/com/util/text/Text'
 import {Button} from 'view/com/util/forms/Button'
 import {NavigationProp} from 'lib/routes/types'
@@ -79,7 +79,7 @@ export const ModerationModlistsScreen = withAuthRequired(
             </Button>
           </View>
         </SimpleViewHeader>
-        <ListsList filter="mod" style={s.flexGrow1} />
+        <MyLists filter="mod" style={s.flexGrow1} />
       </View>
     )
   },
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 23fb088bb..065a03f11 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -10,6 +10,7 @@ import {ViewSelectorHandle} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {Feed} from 'view/com/posts/Feed'
+import {ProfileLists} from '../com/lists/ProfileLists'
 import {useStores} from 'state/index'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
@@ -28,11 +29,10 @@ import {useProfileQuery} from '#/state/queries/profile'
 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 {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
 import {cleanError} from '#/lib/strings/errors'
 
-const SECTION_TITLES_PROFILE = ['Posts', 'Posts & Replies', 'Media', 'Likes']
-
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
 export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({
   route,
@@ -129,6 +129,7 @@ function ProfileScreenLoaded({
   const {_} = useLingui()
   const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
+  const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
 
   useSetTitle(combinedDisplayName(profile))
 
@@ -137,6 +138,21 @@ function ProfileScreenLoaded({
     [profile, moderationOpts],
   )
 
+  const isMe = profile.did === currentAccount?.did
+  const showLikesTab = isMe
+  const showFeedsTab = isMe || extraInfoQuery.data?.hasFeeds
+  const showListsTab = isMe || extraInfoQuery.data?.hasLists
+  const sectionTitles = useMemo<string[]>(() => {
+    return [
+      'Posts',
+      'Posts & Replies',
+      'Media',
+      showLikesTab ? 'Likes' : undefined,
+      showFeedsTab ? 'Feeds' : undefined,
+      showListsTab ? 'Lists' : undefined,
+    ].filter(Boolean) as string[]
+  }, [showLikesTab, showFeedsTab, showListsTab])
+
   /*
     - todo
         - feeds
@@ -204,7 +220,7 @@ function ProfileScreenLoaded({
       moderation={moderation.account}>
       <PagerWithHeader
         isHeaderReady={true}
-        items={SECTION_TITLES_PROFILE}
+        items={sectionTitles}
         onPageSelected={onPageSelected}
         renderHeader={renderHeader}>
         {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
@@ -237,16 +253,40 @@ function ProfileScreenLoaded({
             scrollElRef={scrollElRef}
           />
         )}
-        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
-          <FeedSection
-            ref={null}
-            feed={`likes|${profile.did}`}
-            onScroll={onScroll}
-            headerHeight={headerHeight}
-            isScrolledDown={isScrolledDown}
-            scrollElRef={scrollElRef}
-          />
-        )}
+        {showLikesTab
+          ? ({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+              <FeedSection
+                ref={null}
+                feed={`likes|${profile.did}`}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isScrolledDown={isScrolledDown}
+                scrollElRef={scrollElRef}
+              />
+            )
+          : null}
+        {showFeedsTab
+          ? ({onScroll, headerHeight, scrollElRef}) => (
+              <ProfileLists // TODO put feeds here, using this temporarily to avoid bugs
+                did={profile.did}
+                scrollElRef={scrollElRef}
+                onScroll={onScroll}
+                scrollEventThrottle={1}
+                headerOffset={headerHeight}
+              />
+            )
+          : null}
+        {showListsTab
+          ? ({onScroll, headerHeight, scrollElRef}) => (
+              <ProfileLists
+                did={profile.did}
+                scrollElRef={scrollElRef}
+                onScroll={onScroll}
+                scrollEventThrottle={1}
+                headerOffset={headerHeight}
+              />
+            )
+          : null}
       </PagerWithHeader>
       <FAB
         testID="composeFAB"