about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/cache/profile-shadow.ts88
-rw-r--r--src/state/queries/profile.ts166
-rw-r--r--src/state/queries/resolve-uri.ts15
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx8
-rw-r--r--src/view/com/profile/ProfileHeader.tsx327
-rw-r--r--src/view/screens/PostThread.tsx6
-rw-r--r--src/view/screens/Profile.tsx546
-rw-r--r--src/view/screens/ProfileList.tsx2
-rw-r--r--src/view/shell/desktop/LeftNav.tsx4
9 files changed, 718 insertions, 444 deletions
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
new file mode 100644
index 000000000..a1cf59954
--- /dev/null
+++ b/src/state/cache/profile-shadow.ts
@@ -0,0 +1,88 @@
+import {useEffect, useState, useCallback, useRef} from 'react'
+import EventEmitter from 'eventemitter3'
+import {AppBskyActorDefs} from '@atproto/api'
+
+const emitter = new EventEmitter()
+
+export interface ProfileShadow {
+  followingUri: string | undefined
+  muted: boolean | undefined
+  blockingUri: string | undefined
+}
+
+interface CacheEntry {
+  ts: number
+  value: ProfileShadow
+}
+
+type ProfileView =
+  | AppBskyActorDefs.ProfileView
+  | AppBskyActorDefs.ProfileViewBasic
+  | AppBskyActorDefs.ProfileViewDetailed
+
+export function useProfileShadow<T extends ProfileView>(
+  profile: T,
+  ifAfterTS: number,
+): T {
+  const [state, setState] = useState<CacheEntry>({
+    ts: Date.now(),
+    value: fromProfile(profile),
+  })
+  const firstRun = useRef(true)
+
+  const onUpdate = useCallback(
+    (value: Partial<ProfileShadow>) => {
+      setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
+    },
+    [setState],
+  )
+
+  // react to shadow updates
+  useEffect(() => {
+    emitter.addListener(profile.did, onUpdate)
+    return () => {
+      emitter.removeListener(profile.did, onUpdate)
+    }
+  }, [profile.did, onUpdate])
+
+  // react to profile updates
+  useEffect(() => {
+    // dont fire on first run to avoid needless re-renders
+    if (!firstRun.current) {
+      setState({ts: Date.now(), value: fromProfile(profile)})
+    }
+    firstRun.current = false
+  }, [profile])
+
+  return state.ts > ifAfterTS ? mergeShadow(profile, state.value) : profile
+}
+
+export function updateProfileShadow(
+  uri: string,
+  value: Partial<ProfileShadow>,
+) {
+  emitter.emit(uri, value)
+}
+
+function fromProfile(profile: ProfileView): ProfileShadow {
+  return {
+    followingUri: profile.viewer?.following,
+    muted: profile.viewer?.muted,
+    blockingUri: profile.viewer?.blocking,
+  }
+}
+
+function mergeShadow<T extends ProfileView>(
+  profile: T,
+  shadow: ProfileShadow,
+): T {
+  return {
+    ...profile,
+    viewer: {
+      ...(profile.viewer || {}),
+      following: shadow.followingUri,
+      muted: shadow.muted,
+      blocking: shadow.blockingUri,
+    },
+  }
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index c2cd19482..1bd28d5b1 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -1,13 +1,169 @@
-import {useQuery} from '@tanstack/react-query'
+import {AtUri} from '@atproto/api'
+import {useQuery, useMutation} from '@tanstack/react-query'
+import {useSession} from '../session'
+import {updateProfileShadow} from '../cache/profile-shadow'
 
-import {PUBLIC_BSKY_AGENT} from '#/state/queries'
+export const RQKEY = (did: string) => ['profile', did]
 
-export function useProfileQuery({did}: {did: string}) {
+export function useProfileQuery({did}: {did: string | undefined}) {
+  const {agent} = useSession()
   return useQuery({
-    queryKey: ['getProfile', did],
+    queryKey: RQKEY(did),
     queryFn: async () => {
-      const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did})
+      const res = await agent.getProfile({actor: did || ''})
       return res.data
     },
+    enabled: !!did,
+  })
+}
+
+export function useProfileFollowMutation() {
+  const {agent} = useSession()
+  return useMutation<{uri: string; cid: string}, Error, {did: string}>({
+    mutationFn: async ({did}) => {
+      return await agent.follow(did)
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        followingUri: 'pending',
+      })
+    },
+    onSuccess(data, variables) {
+      // finalize
+      updateProfileShadow(variables.did, {
+        followingUri: data.uri,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        followingUri: undefined,
+      })
+    },
+  })
+}
+
+export function useProfileUnfollowMutation() {
+  const {agent} = useSession()
+  return useMutation<void, Error, {did: string; followUri: string}>({
+    mutationFn: async ({followUri}) => {
+      return await agent.deleteFollow(followUri)
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        followingUri: undefined,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        followingUri: variables.followUri,
+      })
+    },
+  })
+}
+
+export function useProfileMuteMutation() {
+  const {agent} = useSession()
+  return useMutation<void, Error, {did: string}>({
+    mutationFn: async ({did}) => {
+      await agent.mute(did)
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        muted: true,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        muted: false,
+      })
+    },
+  })
+}
+
+export function useProfileUnmuteMutation() {
+  const {agent} = useSession()
+  return useMutation<void, Error, {did: string}>({
+    mutationFn: async ({did}) => {
+      await agent.unmute(did)
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        muted: false,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        muted: true,
+      })
+    },
+  })
+}
+
+export function useProfileBlockMutation() {
+  const {agent, currentAccount} = useSession()
+  return useMutation<{uri: string; cid: string}, Error, {did: string}>({
+    mutationFn: async ({did}) => {
+      if (!currentAccount) {
+        throw new Error('Not signed in')
+      }
+      return await agent.app.bsky.graph.block.create(
+        {repo: currentAccount.did},
+        {subject: did, createdAt: new Date().toISOString()},
+      )
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        blockingUri: 'pending',
+      })
+    },
+    onSuccess(data, variables) {
+      // finalize
+      updateProfileShadow(variables.did, {
+        blockingUri: data.uri,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        blockingUri: undefined,
+      })
+    },
+  })
+}
+
+export function useProfileUnblockMutation() {
+  const {agent, currentAccount} = useSession()
+  return useMutation<void, Error, {did: string; blockUri: string}>({
+    mutationFn: async ({blockUri}) => {
+      if (!currentAccount) {
+        throw new Error('Not signed in')
+      }
+      const {rkey} = new AtUri(blockUri)
+      await agent.app.bsky.graph.block.delete({
+        repo: currentAccount.did,
+        rkey,
+      })
+    },
+    onMutate(variables) {
+      // optimstically update
+      updateProfileShadow(variables.did, {
+        blockingUri: undefined,
+      })
+    },
+    onError(error, variables) {
+      // revert the optimistic update
+      updateProfileShadow(variables.did, {
+        blockingUri: variables.blockUri,
+      })
+    },
   })
 }
diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts
index 26e0a475b..83bccdce7 100644
--- a/src/state/queries/resolve-uri.ts
+++ b/src/state/queries/resolve-uri.ts
@@ -4,17 +4,22 @@ import {useSession} from '../session'
 
 export const RQKEY = (uri: string) => ['resolved-uri', uri]
 
-export function useResolveUriQuery(uri: string) {
+export function useResolveUriQuery(uri: string | undefined) {
   const {agent} = useSession()
-  return useQuery<string | undefined, Error>({
-    queryKey: RQKEY(uri),
+  return useQuery<{uri: string; did: string}, Error>({
+    queryKey: RQKEY(uri || ''),
     async queryFn() {
-      const urip = new AtUri(uri)
+      const urip = new AtUri(uri || '')
       if (!urip.host.startsWith('did:')) {
         const res = await agent.resolveHandle({handle: urip.host})
         urip.host = res.data.did
       }
-      return urip.toString()
+      return {did: urip.host, uri: urip.toString()}
     },
+    enabled: !!uri,
   })
 }
+
+export function useResolveDidQuery(didOrHandle: string | undefined) {
+  return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined)
+}
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index feb4b1c99..e29b35f8a 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -13,6 +13,7 @@ import {logger} from '#/logger'
 import {useModalControls} from '#/state/modals'
 import {FeedDescriptor} from '#/state/queries/post-feed'
 import {EmptyState} from '../util/EmptyState'
+import {cleanError} from '#/lib/strings/errors'
 
 enum KnownError {
   Block,
@@ -69,7 +70,12 @@ export function FeedErrorMessage({
     )
   }
 
-  return <ErrorMessage message={error} onPressTryAgain={onPressTryAgain} />
+  return (
+    <ErrorMessage
+      message={cleanError(error)}
+      onPressTryAgain={onPressTryAgain}
+    />
+  )
 }
 
 function FeedgenErrorMessage({
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index d7b7b8ed7..ea3b86301 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {observer} from 'mobx-react-lite'
 import {
   StyleSheet,
   TouchableOpacity,
@@ -8,15 +7,17 @@ import {
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
+import {
+  AppBskyActorDefs,
+  ProfileModeration,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NavigationProp} from 'lib/routes/types'
+import {isNative} from 'platform/detection'
 import {BlurView} from '../util/BlurView'
-import {ProfileModel} from 'state/models/content/profile'
-import {useStores} from 'state/index'
 import {ProfileImageLightbox} from 'state/models/ui/shell'
-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 {s, colors} from 'lib/styles'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
@@ -25,35 +26,45 @@ import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
 import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
+import {formatCount} from '../util/numeric/format'
+import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
+import {Link} from '../util/Link'
+import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
+import {useStores} from 'state/index'
+import {useModalControls} from '#/state/modals'
+import {
+  useProfileFollowMutation,
+  useProfileUnfollowMutation,
+  useProfileMuteMutation,
+  useProfileUnmuteMutation,
+  useProfileBlockMutation,
+  useProfileUnblockMutation,
+} from '#/state/queries/profile'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {isNative} from 'platform/detection'
-import {FollowState} from 'state/models/cache/my-follows'
-import {shareUrl} from 'lib/sharing'
-import {formatCount} from '../util/numeric/format'
-import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
 import {BACK_HITSLOP} from 'lib/constants'
 import {isInvalidHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
-import {Link} from '../util/Link'
-import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
+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'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {useSession} from '#/state/session'
 
 interface Props {
-  view: ProfileModel
-  onRefreshAll: () => void
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderation: ProfileModeration
   hideBackButton?: boolean
   isProfilePreview?: boolean
 }
 
-export const ProfileHeader = observer(function ProfileHeaderImpl({
-  view,
-  onRefreshAll,
+export function ProfileHeader({
+  profile,
+  moderation,
   hideBackButton = false,
   isProfilePreview,
 }: Props) {
@@ -61,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
 
   // loading
   // =
-  if (!view || !view.hasLoaded) {
+  if (!profile) {
     return (
       <View style={pal.view}>
         <LoadingPlaceholder width="100%" height={153} />
@@ -75,9 +86,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
           </View>
           <View>
             <Text type="title-2xl" style={[pal.text, styles.title]}>
-              {sanitizeDisplayName(
-                view.displayName || sanitizeHandle(view.handle),
-              )}
+              <Trans>Loading...</Trans>
             </Text>
           </View>
         </View>
@@ -85,44 +94,48 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({
     )
   }
 
-  // error
-  // =
-  if (view.hasError) {
-    return (
-      <View testID="profileHeaderHasError">
-        <Text>{view.error}</Text>
-      </View>
-    )
-  }
-
   // loaded
   // =
   return (
     <ProfileHeaderLoaded
-      view={view}
-      onRefreshAll={onRefreshAll}
+      profile={profile}
+      moderation={moderation}
       hideBackButton={hideBackButton}
       isProfilePreview={isProfilePreview}
     />
   )
-})
+}
 
-const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
-  view,
-  onRefreshAll,
+function ProfileHeaderLoaded({
+  profile,
+  moderation,
   hideBackButton = false,
   isProfilePreview,
 }: Props) {
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const store = useStores()
+  const {currentAccount} = useSession()
   const {_} = useLingui()
   const {openModal} = useModalControls()
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
-  const invalidHandle = isInvalidHandle(view.handle)
+  const invalidHandle = isInvalidHandle(profile.handle)
   const {isDesktop} = useWebMediaQueries()
   const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
+  const descriptionRT = React.useMemo(
+    () =>
+      profile.description
+        ? new RichTextAPI({text: profile.description})
+        : undefined,
+    [profile],
+  )
+  const followMutation = useProfileFollowMutation()
+  const unfollowMutation = useProfileUnfollowMutation()
+  const muteMutation = useProfileMuteMutation()
+  const unmuteMutation = useProfileUnmuteMutation()
+  const blockMutation = useProfileBlockMutation()
+  const unblockMutation = useProfileUnblockMutation()
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -134,86 +147,95 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
 
   const onPressAvi = React.useCallback(() => {
     if (
-      view.avatar &&
-      !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+      profile.avatar &&
+      !(moderation.avatar.blur && moderation.avatar.noOverride)
     ) {
-      store.shell.openLightbox(new ProfileImageLightbox(view))
+      store.shell.openLightbox(new ProfileImageLightbox(profile))
     }
-  }, [store, view])
-
-  const onPressToggleFollow = React.useCallback(() => {
-    view?.toggleFollowing().then(
-      () => {
-        setShowSuggestedFollows(Boolean(view.viewer.following))
-        Toast.show(
-          `${
-            view.viewer.following ? 'Following' : 'No longer following'
-          } ${sanitizeDisplayName(view.displayName || view.handle)}`,
-        )
-        track(
-          view.viewer.following
-            ? 'ProfileHeader:FollowButtonClicked'
-            : 'ProfileHeader:UnfollowButtonClicked',
-        )
-      },
-      err => logger.error('Failed to toggle follow', {error: err}),
-    )
-  }, [track, view, setShowSuggestedFollows])
+  }, [store, profile, moderation])
+
+  const onPressFollow = React.useCallback(async () => {
+    if (profile.viewer?.following) {
+      return
+    }
+    try {
+      track('ProfileHeader:FollowButtonClicked')
+      await followMutation.mutateAsync({did: profile.did})
+      Toast.show(
+        `Following ${sanitizeDisplayName(
+          profile.displayName || profile.handle,
+        )}`,
+      )
+    } catch (e: any) {
+      logger.error('Failed to follow', {error: String(e)})
+      Toast.show(`There was an issue! ${e.toString()}`)
+    }
+  }, [followMutation, profile, track])
+
+  const onPressUnfollow = React.useCallback(async () => {
+    if (!profile.viewer?.following) {
+      return
+    }
+    try {
+      track('ProfileHeader:UnfollowButtonClicked')
+      await unfollowMutation.mutateAsync({
+        did: profile.did,
+        followUri: profile.viewer?.following,
+      })
+      Toast.show(
+        `No longer following ${sanitizeDisplayName(
+          profile.displayName || profile.handle,
+        )}`,
+      )
+    } catch (e: any) {
+      logger.error('Failed to unfollow', {error: String(e)})
+      Toast.show(`There was an issue! ${e.toString()}`)
+    }
+  }, [unfollowMutation, profile, track])
 
   const onPressEditProfile = React.useCallback(() => {
     track('ProfileHeader:EditProfileButtonClicked')
     openModal({
       name: 'edit-profile',
-      profileView: view,
-      onUpdate: onRefreshAll,
+      profileView: profile,
     })
-  }, [track, openModal, view, onRefreshAll])
-
-  const trackPress = React.useCallback(
-    (f: 'Followers' | 'Follows') => {
-      track(`ProfileHeader:${f}ButtonClicked`, {
-        handle: view.handle,
-      })
-    },
-    [track, view],
-  )
+  }, [track, openModal, profile])
 
   const onPressShare = React.useCallback(() => {
     track('ProfileHeader:ShareButtonClicked')
-    const url = toShareUrl(makeProfileLink(view))
-    shareUrl(url)
-  }, [track, view])
+    shareUrl(toShareUrl(makeProfileLink(profile)))
+  }, [track, profile])
 
   const onPressAddRemoveLists = React.useCallback(() => {
     track('ProfileHeader:AddToListsButtonClicked')
     openModal({
       name: 'user-add-remove-lists',
-      subject: view.did,
-      displayName: view.displayName || view.handle,
+      subject: profile.did,
+      displayName: profile.displayName || profile.handle,
     })
-  }, [track, view, openModal])
+  }, [track, profile, openModal])
 
   const onPressMuteAccount = React.useCallback(async () => {
     track('ProfileHeader:MuteAccountButtonClicked')
     try {
-      await view.muteAccount()
+      await muteMutation.mutateAsync({did: profile.did})
       Toast.show('Account muted')
     } catch (e: any) {
       logger.error('Failed to mute account', {error: e})
       Toast.show(`There was an issue! ${e.toString()}`)
     }
-  }, [track, view])
+  }, [track, muteMutation, profile])
 
   const onPressUnmuteAccount = React.useCallback(async () => {
     track('ProfileHeader:UnmuteAccountButtonClicked')
     try {
-      await view.unmuteAccount()
+      await unmuteMutation.mutateAsync({did: profile.did})
       Toast.show('Account unmuted')
     } catch (e: any) {
       logger.error('Failed to unmute account', {error: e})
       Toast.show(`There was an issue! ${e.toString()}`)
     }
-  }, [track, view])
+  }, [track, unmuteMutation, profile])
 
   const onPressBlockAccount = React.useCallback(async () => {
     track('ProfileHeader:BlockAccountButtonClicked')
@@ -223,9 +245,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       message:
         'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
       onPressConfirm: async () => {
+        if (profile.viewer?.blocking) {
+          return
+        }
         try {
-          await view.blockAccount()
-          onRefreshAll()
+          await blockMutation.mutateAsync({did: profile.did})
           Toast.show('Account blocked')
         } catch (e: any) {
           logger.error('Failed to block account', {error: e})
@@ -233,7 +257,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         }
       },
     })
-  }, [track, view, openModal, onRefreshAll])
+  }, [track, blockMutation, profile, openModal])
 
   const onPressUnblockAccount = React.useCallback(async () => {
     track('ProfileHeader:UnblockAccountButtonClicked')
@@ -243,9 +267,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       message:
         'The account will be able to interact with you after unblocking.',
       onPressConfirm: async () => {
+        if (!profile.viewer?.blocking) {
+          return
+        }
         try {
-          await view.unblockAccount()
-          onRefreshAll()
+          await unblockMutation.mutateAsync({
+            did: profile.did,
+            blockUri: profile.viewer.blocking,
+          })
           Toast.show('Account unblocked')
         } catch (e: any) {
           logger.error('Failed to unblock account', {error: e})
@@ -253,19 +282,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         }
       },
     })
-  }, [track, view, openModal, onRefreshAll])
+  }, [track, unblockMutation, profile, openModal])
 
   const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
     openModal({
       name: 'report',
-      did: view.did,
+      did: profile.did,
     })
-  }, [track, openModal, view])
+  }, [track, openModal, profile])
 
   const isMe = React.useMemo(
-    () => store.me.did === view.did,
-    [store.me.did, view.did],
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
   )
   const dropdownItems: DropdownItem[] = React.useMemo(() => {
     let items: DropdownItem[] = [
@@ -296,11 +325,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       },
     })
     if (!isMe) {
-      if (!view.viewer.blocking) {
+      if (!profile.viewer?.blocking) {
         items.push({
           testID: 'profileHeaderDropdownMuteBtn',
-          label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
-          onPress: view.viewer.muted
+          label: profile.viewer?.muted ? 'Unmute Account' : 'Mute Account',
+          onPress: profile.viewer?.muted
             ? onPressUnmuteAccount
             : onPressMuteAccount,
           icon: {
@@ -312,11 +341,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
           },
         })
       }
-      if (!view.viewer.blockingByList) {
+      if (!profile.viewer?.blockingByList) {
         items.push({
           testID: 'profileHeaderDropdownBlockBtn',
-          label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
-          onPress: view.viewer.blocking
+          label: profile.viewer?.blocking ? 'Unblock Account' : 'Block Account',
+          onPress: profile.viewer?.blocking
             ? onPressUnblockAccount
             : onPressBlockAccount,
           icon: {
@@ -344,9 +373,9 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
     return items
   }, [
     isMe,
-    view.viewer.muted,
-    view.viewer.blocking,
-    view.viewer.blockingByList,
+    profile.viewer?.muted,
+    profile.viewer?.blocking,
+    profile.viewer?.blockingByList,
     onPressShare,
     onPressUnmuteAccount,
     onPressMuteAccount,
@@ -356,14 +385,15 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
     onPressAddRemoveLists,
   ])
 
-  const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
-  const following = formatCount(view.followsCount)
-  const followers = formatCount(view.followersCount)
-  const pluralizedFollowers = pluralize(view.followersCount, 'follower')
+  const blockHide =
+    !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
+  const following = formatCount(profile.followsCount || 0)
+  const followers = formatCount(profile.followersCount || 0)
+  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
 
   return (
     <View style={pal.view}>
-      <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
+      <UserBanner banner={profile.banner} moderation={moderation.avatar} />
       <View style={styles.content}>
         <View style={[styles.buttonsLine]}>
           {isMe ? (
@@ -378,8 +408,8 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                 <Trans>Edit Profile</Trans>
               </Text>
             </TouchableOpacity>
-          ) : view.viewer.blocking ? (
-            view.viewer.blockingByList ? null : (
+          ) : profile.viewer?.blocking ? (
+            profile.viewer?.blockingByList ? null : (
               <TouchableOpacity
                 testID="unblockBtn"
                 onPress={onPressUnblockAccount}
@@ -392,7 +422,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                 </Text>
               </TouchableOpacity>
             )
-          ) : !view.viewer.blockedBy ? (
+          ) : !profile.viewer?.blockedBy ? (
             <>
               {!isProfilePreview && (
                 <TouchableOpacity
@@ -410,7 +440,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                     },
                   ]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Show follows similar to ${view.handle}`}
+                  accessibilityLabel={`Show follows similar to ${profile.handle}`}
                   accessibilityHint={`Shows a list of users similar to this user.`}>
                   <FontAwesomeIcon
                     icon="user-plus"
@@ -427,15 +457,14 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                 </TouchableOpacity>
               )}
 
-              {store.me.follows.getFollowState(view.did) ===
-              FollowState.Following ? (
+              {profile.viewer?.following ? (
                 <TouchableOpacity
                   testID="unfollowBtn"
-                  onPress={onPressToggleFollow}
+                  onPress={onPressUnfollow}
                   style={[styles.btn, styles.mainBtn, pal.btn]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Unfollow ${view.handle}`}
-                  accessibilityHint={`Hides posts from ${view.handle} in your feed`}>
+                  accessibilityLabel={`Unfollow ${profile.handle}`}
+                  accessibilityHint={`Hides posts from ${profile.handle} in your feed`}>
                   <FontAwesomeIcon
                     icon="check"
                     style={[pal.text, s.mr5]}
@@ -448,11 +477,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               ) : (
                 <TouchableOpacity
                   testID="followBtn"
-                  onPress={onPressToggleFollow}
+                  onPress={onPressFollow}
                   style={[styles.btn, styles.mainBtn, palInverted.view]}
                   accessibilityRole="button"
-                  accessibilityLabel={`Follow ${view.handle}`}
-                  accessibilityHint={`Shows posts from ${view.handle} in your feed`}>
+                  accessibilityLabel={`Follow ${profile.handle}`}
+                  accessibilityHint={`Shows posts from ${profile.handle} in your feed`}>
                   <FontAwesomeIcon
                     icon="plus"
                     style={[palInverted.text, s.mr5]}
@@ -482,13 +511,13 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
             type="title-2xl"
             style={[pal.text, styles.title]}>
             {sanitizeDisplayName(
-              view.displayName || sanitizeHandle(view.handle),
-              view.moderation.profile,
+              profile.displayName || sanitizeHandle(profile.handle),
+              moderation.profile,
             )}
           </Text>
         </View>
         <View style={styles.handleLine}>
-          {view.viewer.followedBy && !blockHide ? (
+          {profile.viewer?.followedBy && !blockHide ? (
             <View style={[styles.pill, pal.btn, s.mr5]}>
               <Text type="xs" style={[pal.text]}>
                 <Trans>Follows you</Trans>
@@ -503,7 +532,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               invalidHandle ? styles.invalidHandle : undefined,
               styles.handle,
             ]}>
-            {invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`}
+            {invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`}
           </ThemedText>
         </View>
         {!blockHide && (
@@ -512,8 +541,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               <Link
                 testID="profileHeaderFollowersButton"
                 style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(view, 'followers')}
-                onPressOut={() => trackPress('Followers')}
+                href={makeProfileLink(profile, 'followers')}
+                onPressOut={() =>
+                  track(`ProfileHeader:FollowersButtonClicked`, {
+                    handle: profile.handle,
+                  })
+                }
                 asAnchor
                 accessibilityLabel={`${followers} ${pluralizedFollowers}`}
                 accessibilityHint={'Opens followers list'}>
@@ -527,8 +560,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
               <Link
                 testID="profileHeaderFollowsButton"
                 style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(view, 'follows')}
-                onPressOut={() => trackPress('Follows')}
+                href={makeProfileLink(profile, 'follows')}
+                onPressOut={() =>
+                  track(`ProfileHeader:FollowsButtonClicked`, {
+                    handle: profile.handle,
+                  })
+                }
                 asAnchor
                 accessibilityLabel={`${following} following`}
                 accessibilityHint={'Opens following list'}>
@@ -540,30 +577,28 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
                 </Text>
               </Link>
               <Text type="md" style={[s.bold, pal.text]}>
-                {formatCount(view.postsCount)}{' '}
+                {formatCount(profile.postsCount || 0)}{' '}
                 <Text type="md" style={[pal.textLight]}>
-                  {pluralize(view.postsCount, 'post')}
+                  {pluralize(profile.postsCount || 0, 'post')}
                 </Text>
               </Text>
             </View>
-            {view.description &&
-            view.descriptionRichText &&
-            !view.moderation.profile.blur ? (
+            {descriptionRT && !moderation.profile.blur ? (
               <RichText
                 testID="profileHeaderDescription"
                 style={[styles.description, pal.text]}
                 numberOfLines={15}
-                richText={view.descriptionRichText}
+                richText={descriptionRT}
               />
             ) : undefined}
           </>
         )}
-        <ProfileHeaderAlerts moderation={view.moderation} />
+        <ProfileHeaderAlerts moderation={moderation} />
       </View>
 
       {!isProfilePreview && (
         <ProfileHeaderSuggestedFollows
-          actorDid={view.did}
+          actorDid={profile.did}
           active={showSuggestedFollows}
           requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)}
         />
@@ -588,20 +623,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
         testID="profileHeaderAviButton"
         onPress={onPressAvi}
         accessibilityRole="image"
-        accessibilityLabel={`View ${view.handle}'s avatar`}
+        accessibilityLabel={`View ${profile.handle}'s avatar`}
         accessibilityHint="">
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
           <UserAvatar
             size={80}
-            avatar={view.avatar}
-            moderation={view.moderation.avatar}
+            avatar={profile.avatar}
+            moderation={moderation.avatar}
           />
         </View>
       </TouchableWithoutFeedback>
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   banner: {
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index b254c1eca..9536e86e7 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -49,7 +49,7 @@ export const PostThreadScreen = withAuthRequired(
         return
       }
       const thread = queryClient.getQueryData<ThreadNode>(
-        POST_THREAD_RQKEY(resolvedUri),
+        POST_THREAD_RQKEY(resolvedUri.uri),
       )
       if (thread?.type !== 'post') {
         return
@@ -67,7 +67,7 @@ export const PostThreadScreen = withAuthRequired(
         },
         onPost: () =>
           queryClient.invalidateQueries({
-            queryKey: POST_THREAD_RQKEY(resolvedUri || ''),
+            queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''),
           }),
       })
     }, [store, queryClient, resolvedUri])
@@ -82,7 +82,7 @@ export const PostThreadScreen = withAuthRequired(
             </CenteredView>
           ) : (
             <PostThreadComponent
-              uri={resolvedUri}
+              uri={resolvedUri?.uri}
               onPressReply={onPressReply}
               treeView={!!store.preferences.thread.lab_treeViewEnabled}
             />
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 945a8cc20..dab8988ad 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,323 +1,307 @@
-import React, {useEffect, useState} from 'react'
+import React, {useMemo} from 'react'
 import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {useFocusEffect} from '@react-navigation/native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector'
+import {ViewSelectorHandle} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
 import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
-import {ProfileUiModel, Sections} from 'state/models/ui/profile'
+import {Feed} from 'view/com/posts/Feed'
 import {useStores} from 'state/index'
 import {ProfileHeader} from '../com/profile/ProfileHeader'
-import {FeedSlice} from '../com/posts/FeedSlice'
-import {ListCard} from 'view/com/lists/ListCard'
-import {
-  PostFeedLoadingPlaceholder,
-  ProfileCardFeedLoadingPlaceholder,
-} from '../com/util/LoadingPlaceholder'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
-import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {EmptyState} from '../com/util/EmptyState'
-import {Text} from '../com/util/text/Text'
 import {FAB} from '../com/util/fab/FAB'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ComposeIcon2} from 'lib/icons'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {FeedSourceModel} from 'state/models/content/feed-source'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useSetMinimalShellMode} from '#/state/shell'
+import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+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 {useModerationOpts} from '#/state/queries/preferences'
+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(
-  observer(function ProfileScreenImpl({route}: Props) {
-    const store = useStores()
-    const setMinimalShellMode = useSetMinimalShellMode()
-    const {screen, track} = useAnalytics()
-    const {_} = useLingui()
-    const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
-    const name = route.params.name === 'me' ? store.me.did : route.params.name
+export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({
+  route,
+}: Props) {
+  const {currentAccount} = useSession()
+  const name =
+    route.params.name === 'me' ? currentAccount?.did : route.params.name
+  const moderationOpts = useModerationOpts()
+  const {
+    data: resolvedDid,
+    error: resolveError,
+    refetch: refetchDid,
+    isFetching: isFetchingDid,
+  } = useResolveDidQuery(name)
+  const {
+    data: profile,
+    dataUpdatedAt,
+    error: profileError,
+    refetch: refetchProfile,
+    isFetching: isFetchingProfile,
+  } = useProfileQuery({
+    did: resolvedDid?.did,
+  })
 
-    useEffect(() => {
-      screen('Profile')
-    }, [screen])
+  const onPressTryAgain = React.useCallback(() => {
+    if (resolveError) {
+      refetchDid()
+    } else {
+      refetchProfile()
+    }
+  }, [resolveError, refetchDid, refetchProfile])
 
-    const [hasSetup, setHasSetup] = useState<boolean>(false)
-    const uiState = React.useMemo(
-      () => new ProfileUiModel(store, {user: name}),
-      [name, store],
+  if (isFetchingDid || isFetchingProfile) {
+    return (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
     )
-    useSetTitle(combinedDisplayName(uiState.profile))
+  }
+  if (resolveError || profileError) {
+    return (
+      <CenteredView>
+        <ErrorScreen
+          testID="profileErrorScreen"
+          title="Oops!"
+          message={cleanError(resolveError || profileError)}
+          onPressTryAgain={onPressTryAgain}
+        />
+      </CenteredView>
+    )
+  }
+  if (profile && moderationOpts) {
+    return (
+      <ProfileScreenLoaded
+        profile={profile}
+        dataUpdatedAt={dataUpdatedAt}
+        moderationOpts={moderationOpts}
+        hideBackButton={!!route.params.hideBackButton}
+      />
+    )
+  }
+  // should never happen
+  return (
+    <CenteredView>
+      <ErrorScreen
+        testID="profileErrorScreen"
+        title="Oops!"
+        message="Something went wrong and we're not sure what."
+        onPressTryAgain={onPressTryAgain}
+      />
+    </CenteredView>
+  )
+})
 
-    const onSoftReset = React.useCallback(() => {
-      viewSelectorRef.current?.scrollToTop()
-    }, [])
+function ProfileScreenLoaded({
+  profile: profileUnshadowed,
+  dataUpdatedAt,
+  moderationOpts,
+  hideBackButton,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  dataUpdatedAt: number
+  moderationOpts: ModerationOpts
+  hideBackButton: boolean
+}) {
+  const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
+  const store = useStores()
+  const {currentAccount} = useSession()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen, track} = useAnalytics()
+  const [currentPage, setCurrentPage] = React.useState(0)
+  const {_} = useLingui()
+  const viewSelectorRef = React.useRef<ViewSelectorHandle>(null)
+  const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
 
-    useEffect(() => {
-      setHasSetup(false)
-    }, [name])
+  useSetTitle(combinedDisplayName(profile))
 
-    // We don't need this to be reactive, so we can just register the listeners once
-    useEffect(() => {
-      const listCleanup = uiState.lists.registerListeners()
-      return () => listCleanup()
-      // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [])
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
 
-    useFocusEffect(
-      React.useCallback(() => {
-        const softResetSub = store.onScreenSoftReset(onSoftReset)
-        let aborted = false
-        setMinimalShellMode(false)
-        const feedCleanup = uiState.feed.registerListeners()
-        if (!hasSetup) {
-          uiState.setup().then(() => {
-            if (aborted) {
-              return
-            }
-            setHasSetup(true)
-          })
-        }
-        return () => {
-          aborted = true
-          feedCleanup()
-          softResetSub.remove()
-        }
-      }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]),
-    )
+  /*
+    - todo
+        - feeds
+        - lists
+    */
 
-    // events
-    // =
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+      screen('Profile')
+      const softResetSub = store.onScreenSoftReset(() => {
+        viewSelectorRef.current?.scrollToTop()
+      })
+      return () => softResetSub.remove()
+    }, [store, viewSelectorRef, setMinimalShellMode, screen]),
+  )
 
-    const onPressCompose = React.useCallback(() => {
-      track('ProfileScreen:PressCompose')
-      const mention =
-        uiState.profile.handle === store.me.handle ||
-        uiState.profile.handle === 'handle.invalid'
-          ? undefined
-          : uiState.profile.handle
-      store.shell.openComposer({mention})
-    }, [store, track, uiState])
-    const onSelectView = React.useCallback(
-      (index: number) => {
-        uiState.setSelectedViewIndex(index)
-      },
-      [uiState],
-    )
-    const onRefresh = React.useCallback(() => {
-      uiState
-        .refresh()
-        .catch((err: any) =>
-          logger.error('Failed to refresh user profile', {error: err}),
-        )
-    }, [uiState])
-    const onEndReached = React.useCallback(() => {
-      uiState.loadMore().catch((err: any) =>
-        logger.error('Failed to load more entries in user profile', {
-          error: err,
-        }),
-      )
-    }, [uiState])
-    const onPressTryAgain = React.useCallback(() => {
-      uiState.setup()
-    }, [uiState])
+  useFocusEffect(
+    React.useCallback(() => {
+      setDrawerSwipeDisabled(currentPage > 0)
+      return () => {
+        setDrawerSwipeDisabled(false)
+      }
+    }, [setDrawerSwipeDisabled, currentPage]),
+  )
 
-    // rendering
-    // =
+  // events
+  // =
 
-    const renderHeader = React.useCallback(() => {
-      if (!uiState) {
-        return <View />
-      }
-      return (
-        <ProfileHeader
-          view={uiState.profile}
-          onRefreshAll={onRefresh}
-          hideBackButton={route.params.hideBackButton}
-        />
-      )
-    }, [uiState, onRefresh, route.params.hideBackButton])
+  const onPressCompose = React.useCallback(() => {
+    track('ProfileScreen:PressCompose')
+    const mention =
+      profile.handle === currentAccount?.handle ||
+      profile.handle === 'handle.invalid'
+        ? undefined
+        : profile.handle
+    store.shell.openComposer({mention})
+  }, [store, currentAccount, track, profile])
 
-    const Footer = React.useMemo(() => {
-      return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined
-    }, [uiState.showLoadingMoreFooter])
-    const renderItem = React.useCallback(
-      (item: any) => {
-        // if section is lists
-        if (uiState.selectedView === Sections.Lists) {
-          if (item === ProfileUiModel.LOADING_ITEM) {
-            return <ProfileCardFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                testID="listsEmpty"
-                icon="list-ul"
-                message="No lists yet!"
-                style={styles.emptyState}
-              />
-            )
-          } else {
-            return <ListCard testID={`list-${item.name}`} list={item} />
-          }
-          // if section is custom algorithms
-        } else if (uiState.selectedView === Sections.CustomAlgorithms) {
-          if (item === ProfileUiModel.LOADING_ITEM) {
-            return <ProfileCardFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                testID="customAlgorithmsEmpty"
-                icon="list-ul"
-                message="No custom algorithms yet!"
-                style={styles.emptyState}
-              />
-            )
-          } else if (item instanceof FeedSourceModel) {
-            return (
-              <FeedSourceCard
-                item={item}
-                showSaveBtn
-                showLikes
-                showDescription
-              />
-            )
-          }
-          // if section is posts or posts & replies
-        } else {
-          if (item === ProfileUiModel.END_ITEM) {
-            return (
-              <Text style={styles.endItem}>
-                <Trans>- end of feed -</Trans>
-              </Text>
-            )
-          } else if (item === ProfileUiModel.LOADING_ITEM) {
-            return <PostFeedLoadingPlaceholder />
-          } else if (item._reactKey === '__error__') {
-            if (uiState.feed.isBlocking) {
-              return (
-                <EmptyState
-                  icon="ban"
-                  message="Posts hidden"
-                  style={styles.emptyState}
-                />
-              )
-            }
-            if (uiState.feed.isBlockedBy) {
-              return (
-                <EmptyState
-                  icon="ban"
-                  message="Posts hidden"
-                  style={styles.emptyState}
-                />
-              )
-            }
-            return (
-              <View style={s.p5}>
-                <ErrorMessage
-                  message={item.error}
-                  onPressTryAgain={onPressTryAgain}
-                />
-              </View>
-            )
-          } else if (item === ProfileUiModel.EMPTY_ITEM) {
-            return (
-              <EmptyState
-                icon={['far', 'message']}
-                message="No posts yet!"
-                style={styles.emptyState}
-              />
-            )
-          } else if (item instanceof PostsFeedSliceModel) {
-            return (
-              <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} />
-            )
-          }
-        }
-        return <View />
-      },
-      [
-        onPressTryAgain,
-        uiState.selectedView,
-        uiState.profile.did,
-        uiState.feed.isBlocking,
-        uiState.feed.isBlockedBy,
-      ],
-    )
+  const onPageSelected = React.useCallback(
+    i => {
+      setCurrentPage(i)
+    },
+    [setCurrentPage],
+  )
+
+  // rendering
+  // =
 
+  const renderHeader = React.useCallback(() => {
     return (
-      <ScreenHider
-        testID="profileView"
-        style={styles.container}
-        screenDescription="profile"
-        moderation={uiState.profile.moderation.account}>
-        {uiState.profile.hasError ? (
-          <ErrorScreen
-            testID="profileErrorScreen"
-            title="Failed to load profile"
-            message={uiState.profile.error}
-            onPressTryAgain={onPressTryAgain}
+      <ProfileHeader
+        profile={profile}
+        moderation={moderation}
+        hideBackButton={hideBackButton}
+      />
+    )
+  }, [profile, moderation, hideBackButton])
+
+  return (
+    <ScreenHider
+      testID="profileView"
+      style={styles.container}
+      screenDescription="profile"
+      moderation={moderation.account}>
+      <PagerWithHeader
+        isHeaderReady={true}
+        items={SECTION_TITLES_PROFILE}
+        onPageSelected={onPageSelected}
+        renderHeader={renderHeader}>
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={null}
+            feed={`author|${profile.did}|posts_no_replies`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={scrollElRef}
+          />
+        )}
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={null}
+            feed={`author|${profile.did}|posts_with_replies`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={scrollElRef}
+          />
+        )}
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={null}
+            feed={`author|${profile.did}|posts_with_media`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={scrollElRef}
           />
-        ) : uiState.profile.hasLoaded ? (
-          <ViewSelector
-            ref={viewSelectorRef}
-            swipeEnabled={false}
-            sections={uiState.selectorItems}
-            items={uiState.uiItems}
-            renderHeader={renderHeader}
-            renderItem={renderItem}
-            ListFooterComponent={Footer}
-            refreshing={uiState.isRefreshing || false}
-            onSelectView={onSelectView}
-            onRefresh={onRefresh}
-            onEndReached={onEndReached}
+        )}
+        {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
+          <FeedSection
+            ref={null}
+            feed={`likes|${profile.did}`}
+            onScroll={onScroll}
+            headerHeight={headerHeight}
+            isScrolledDown={isScrolledDown}
+            scrollElRef={scrollElRef}
           />
-        ) : (
-          <CenteredView>{renderHeader()}</CenteredView>
         )}
-        <FAB
-          testID="composeFAB"
-          onPress={onPressCompose}
-          icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`New post`)}
-          accessibilityHint=""
+      </PagerWithHeader>
+      <FAB
+        testID="composeFAB"
+        onPress={onPressCompose}
+        icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New post`)}
+        accessibilityHint=""
+      />
+    </ScreenHider>
+  )
+}
+
+interface FeedSectionProps {
+  feed: FeedDescriptor
+  onScroll: OnScrollHandler
+  headerHeight: number
+  isScrolledDown: boolean
+  scrollElRef: any /* TODO */
+}
+const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
+  function FeedSectionImpl(
+    {feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
+    ref,
+  ) {
+    const hasNew = false //TODO feed.hasNewLatest && !feed.isRefreshing
+
+    const onScrollToTop = React.useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+      // feed.refresh() TODO
+    }, [feed, scrollElRef, headerHeight])
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderPostsEmpty = React.useCallback(() => {
+      return <EmptyState icon="feed" message="This feed is empty!" />
+    }, [])
+
+    return (
+      <View>
+        <Feed
+          testID="postsFeed"
+          feed={feed}
+          scrollElRef={scrollElRef}
+          onScroll={onScroll}
+          scrollEventThrottle={1}
+          renderEmptyState={renderPostsEmpty}
+          headerOffset={headerHeight}
         />
-      </ScreenHider>
+      </View>
     )
-  }),
+  },
 )
 
-function LoadingMoreFooter() {
-  return (
-    <View style={styles.loadingMoreFooter}>
-      <ActivityIndicator />
-    </View>
-  )
-}
-
 const styles = StyleSheet.create({
   container: {
     flexDirection: 'column',
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 18665f519..42c3741db 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -70,7 +70,7 @@ export const ProfileListScreen = withAuthRequired(
     const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
       AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
     )
-    const {data: list, error: listError} = useListQuery(resolvedUri)
+    const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
 
     if (resolveError) {
       return (
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index c9a03ce62..d7814cb5d 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -251,7 +251,7 @@ function ComposeBtn() {
 }
 
 export const DesktopLeftNav = observer(function DesktopLeftNav() {
-  const store = useStores()
+  const {currentAccount} = useSession()
   const pal = usePalette('default')
   const {isDesktop, isTablet} = useWebMediaQueries()
   const numUnread = useUnreadNotifications()
@@ -370,7 +370,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() {
         label="Moderation"
       />
       <NavItem
-        href={makeProfileLink(store.me)}
+        href={makeProfileLink(currentAccount)}
         icon={
           <UserIcon
             strokeWidth={1.75}