about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/Composer.tsx6
-rw-r--r--src/view/com/modals/Confirm.tsx3
-rw-r--r--src/view/com/post-thread/PostThread.tsx64
-rw-r--r--src/view/com/posts/FeedSlice.tsx4
-rw-r--r--src/view/com/profile/ProfileCard.tsx7
-rw-r--r--src/view/com/profile/ProfileHeader.tsx625
-rw-r--r--src/view/index.ts2
-rw-r--r--src/view/screens/AppPasswords.tsx2
-rw-r--r--src/view/screens/BlockedAccounts.tsx172
-rw-r--r--src/view/screens/Profile.tsx25
-rw-r--r--src/view/screens/Settings.tsx22
11 files changed, 659 insertions, 273 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index c30d881ec..5ccc229d6 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -190,11 +190,7 @@ export const ComposePost = observer(function ComposePost({
 
   const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
 
-  const selectTextInputPlaceholder = replyTo
-    ? 'Write your reply'
-    : gallery.isEmpty
-    ? 'Write a comment'
-    : "What's up?"
+  const selectTextInputPlaceholder = replyTo ? 'Write your reply' : "What's up?"
 
   const canSelectImages = gallery.size < 4
   const viewStyles = {
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 63877fe5d..6f7b062cf 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -11,6 +11,7 @@ import {s, colors} from 'lib/styles'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
 
 export const snapPoints = [300]
 
@@ -77,7 +78,7 @@ const styles = StyleSheet.create({
   container: {
     flex: 1,
     padding: 10,
-    paddingBottom: 60,
+    paddingBottom: isDesktopWeb ? 0 : 60,
   },
   title: {
     textAlign: 'center',
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 6e387b8d0..fe1822acb 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -7,6 +7,7 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {CenteredView, FlatList} from '../util/Views'
 import {
   PostThreadModel,
@@ -27,11 +28,17 @@ import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 
 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
+const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
+const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
 const BOTTOM_COMPONENT = {
   _reactKey: '__bottom_component__',
   _isHighlightedPost: false,
 }
-type YieldedItem = PostThreadItemModel | typeof REPLY_PROMPT
+type YieldedItem =
+  | PostThreadItemModel
+  | typeof REPLY_PROMPT
+  | typeof DELETED
+  | typeof BLOCKED
 
 export const PostThread = observer(function PostThread({
   uri,
@@ -103,6 +110,22 @@ export const PostThread = observer(function PostThread({
     ({item}: {item: YieldedItem}) => {
       if (item === REPLY_PROMPT) {
         return <ComposePrompt onPressCompose={onPressReply} />
+      } else if (item === DELETED) {
+        return (
+          <View style={[pal.border, pal.viewLight, styles.missingItem]}>
+            <Text type="lg-bold" style={pal.textLight}>
+              Deleted post.
+            </Text>
+          </View>
+        )
+      } else if (item === BLOCKED) {
+        return (
+          <View style={[pal.border, pal.viewLight, styles.missingItem]}>
+            <Text type="lg-bold" style={pal.textLight}>
+              Blocked post.
+            </Text>
+          </View>
+        )
       } else if (item === BOTTOM_COMPONENT) {
         // HACK
         // due to some complexities with how flatlist works, this is the easiest way
@@ -177,6 +200,30 @@ export const PostThread = observer(function PostThread({
       </CenteredView>
     )
   }
+  if (view.isBlocked) {
+    return (
+      <CenteredView>
+        <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+          <Text type="title-lg" style={[pal.text, s.mb5]}>
+            Post hidden
+          </Text>
+          <Text type="md" style={[pal.text, s.mb10]}>
+            You have blocked the author or you have been blocked by the author.
+          </Text>
+          <TouchableOpacity onPress={onPressBack}>
+            <Text type="2xl" style={pal.link}>
+              <FontAwesomeIcon
+                icon="angle-left"
+                style={[pal.link as FontAwesomeIconStyle, s.mr5]}
+                size={14}
+              />
+              Back
+            </Text>
+          </TouchableOpacity>
+        </View>
+      </CenteredView>
+    )
+  }
 
   // loaded
   // =
@@ -208,8 +255,10 @@ function* flattenThread(
   isAscending = false,
 ): Generator<YieldedItem, void> {
   if (post.parent) {
-    if ('notFound' in post.parent && post.parent.notFound) {
-      // TODO render not found
+    if (AppBskyFeedDefs.isNotFoundPost(post.parent)) {
+      yield DELETED
+    } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) {
+      yield BLOCKED
     } else {
       yield* flattenThread(post.parent as PostThreadItemModel, true)
     }
@@ -220,8 +269,8 @@ function* flattenThread(
   }
   if (post.replies?.length) {
     for (const reply of post.replies) {
-      if ('notFound' in reply && reply.notFound) {
-        // TODO render not found
+      if (AppBskyFeedDefs.isNotFoundPost(reply)) {
+        yield DELETED
       } else {
         yield* flattenThread(reply as PostThreadItemModel)
       }
@@ -238,6 +287,11 @@ const styles = StyleSheet.create({
     paddingVertical: 14,
     borderRadius: 6,
   },
+  missingItem: {
+    borderTop: 1,
+    paddingHorizontal: 18,
+    paddingVertical: 18,
+  },
   bottomBorder: {
     borderBottomWidth: 1,
   },
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index 651b69bff..5a191ac10 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -7,6 +7,7 @@ import {Text} from '../util/text/Text'
 import Svg, {Circle, Line} from 'react-native-svg'
 import {FeedItem} from './FeedItem'
 import {usePalette} from 'lib/hooks/usePalette'
+import {ModerationBehaviorCode} from 'lib/labeling/types'
 
 export function FeedSlice({
   slice,
@@ -17,6 +18,9 @@ export function FeedSlice({
   showFollowBtn?: boolean
   ignoreMuteFor?: string
 }) {
+  if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
+    return null
+  }
   if (slice.isThread && slice.items.length > 3) {
     const last = slice.items.length - 1
     return (
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 154344388..66c172141 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -23,6 +23,7 @@ export const ProfileCard = observer(
     noBg,
     noBorder,
     followers,
+    overrideModeration,
     renderButton,
   }: {
     testID?: string
@@ -30,6 +31,7 @@ export const ProfileCard = observer(
     noBg?: boolean
     noBorder?: boolean
     followers?: AppBskyActorDefs.ProfileView[] | undefined
+    overrideModeration?: boolean
     renderButton?: () => JSX.Element
   }) => {
     const store = useStores()
@@ -40,7 +42,10 @@ export const ProfileCard = observer(
       getProfileViewBasicLabelInfo(profile),
     )
 
-    if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
+    if (
+      moderation.list.behavior === ModerationBehaviorCode.Hide &&
+      !overrideModeration
+    ) {
       return null
     }
 
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index d1104d184..719b84e20 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -96,281 +96,377 @@ export const ProfileHeader = observer(
   },
 )
 
-const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
-  view,
-  onRefreshAll,
-  hideBackButton = false,
-}: Props) {
-  const pal = usePalette('default')
-  const store = useStores()
-  const navigation = useNavigation<NavigationProp>()
-  const {track} = useAnalytics()
-
-  const onPressBack = React.useCallback(() => {
-    navigation.goBack()
-  }, [navigation])
-
-  const onPressAvi = React.useCallback(() => {
-    if (view.avatar) {
-      store.shell.openLightbox(new ProfileImageLightbox(view))
-    }
-  }, [store, view])
-
-  const onPressToggleFollow = React.useCallback(() => {
-    view?.toggleFollowing().then(
-      () => {
-        Toast.show(
-          `${
-            view.viewer.following ? 'Following' : 'No longer following'
-          } ${sanitizeDisplayName(view.displayName || view.handle)}`,
-        )
-      },
-      err => store.log.error('Failed to toggle follow', err),
-    )
-  }, [view, store])
-
-  const onPressEditProfile = React.useCallback(() => {
-    track('ProfileHeader:EditProfileButtonClicked')
-    store.shell.openModal({
-      name: 'edit-profile',
-      profileView: view,
-      onUpdate: onRefreshAll,
-    })
-  }, [track, store, view, onRefreshAll])
-
-  const onPressFollowers = React.useCallback(() => {
-    track('ProfileHeader:FollowersButtonClicked')
-    navigation.push('ProfileFollowers', {name: view.handle})
-  }, [track, navigation, view])
-
-  const onPressFollows = React.useCallback(() => {
-    track('ProfileHeader:FollowsButtonClicked')
-    navigation.push('ProfileFollows', {name: view.handle})
-  }, [track, navigation, view])
-
-  const onPressShare = React.useCallback(async () => {
-    track('ProfileHeader:ShareButtonClicked')
-    const url = toShareUrl(`/profile/${view.handle}`)
-    shareUrl(url)
-  }, [track, view])
-
-  const onPressMuteAccount = React.useCallback(async () => {
-    track('ProfileHeader:MuteAccountButtonClicked')
-    try {
-      await view.muteAccount()
-      Toast.show('Account muted')
-    } catch (e: any) {
-      store.log.error('Failed to mute account', e)
-      Toast.show(`There was an issue! ${e.toString()}`)
-    }
-  }, [track, view, store])
-
-  const onPressUnmuteAccount = React.useCallback(async () => {
-    track('ProfileHeader:UnmuteAccountButtonClicked')
-    try {
-      await view.unmuteAccount()
-      Toast.show('Account unmuted')
-    } catch (e: any) {
-      store.log.error('Failed to unmute account', e)
-      Toast.show(`There was an issue! ${e.toString()}`)
-    }
-  }, [track, view, store])
-
-  const onPressReportAccount = React.useCallback(() => {
-    track('ProfileHeader:ReportAccountButtonClicked')
-    store.shell.openModal({
-      name: 'report-account',
-      did: view.did,
-    })
-  }, [track, store, view])
-
-  const isMe = React.useMemo(
-    () => store.me.did === view.did,
-    [store.me.did, view.did],
-  )
-  const dropdownItems: DropdownItem[] = React.useMemo(() => {
-    let items: DropdownItem[] = [
-      {
-        testID: 'profileHeaderDropdownSahreBtn',
-        label: 'Share',
-        onPress: onPressShare,
-      },
-    ]
-    if (!isMe) {
-      items.push({
-        testID: 'profileHeaderDropdownMuteBtn',
-        label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
-        onPress: view.viewer.muted ? onPressUnmuteAccount : onPressMuteAccount,
+const ProfileHeaderLoaded = observer(
+  ({view, onRefreshAll, hideBackButton = false}: Props) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const navigation = useNavigation<NavigationProp>()
+    const {track} = useAnalytics()
+
+    const onPressBack = React.useCallback(() => {
+      navigation.goBack()
+    }, [navigation])
+
+    const onPressAvi = React.useCallback(() => {
+      if (view.avatar) {
+        store.shell.openLightbox(new ProfileImageLightbox(view))
+      }
+    }, [store, view])
+
+    const onPressToggleFollow = React.useCallback(() => {
+      view?.toggleFollowing().then(
+        () => {
+          Toast.show(
+            `${
+              view.viewer.following ? 'Following' : 'No longer following'
+            } ${sanitizeDisplayName(view.displayName || view.handle)}`,
+          )
+        },
+        err => store.log.error('Failed to toggle follow', err),
+      )
+    }, [view, store])
+
+    const onPressEditProfile = React.useCallback(() => {
+      track('ProfileHeader:EditProfileButtonClicked')
+      store.shell.openModal({
+        name: 'edit-profile',
+        profileView: view,
+        onUpdate: onRefreshAll,
+      })
+    }, [track, store, view, onRefreshAll])
+
+    const onPressFollowers = React.useCallback(() => {
+      track('ProfileHeader:FollowersButtonClicked')
+      navigation.push('ProfileFollowers', {name: view.handle})
+    }, [track, navigation, view])
+
+    const onPressFollows = React.useCallback(() => {
+      track('ProfileHeader:FollowsButtonClicked')
+      navigation.push('ProfileFollows', {name: view.handle})
+    }, [track, navigation, view])
+
+    const onPressShare = React.useCallback(async () => {
+      track('ProfileHeader:ShareButtonClicked')
+      const url = toShareUrl(`/profile/${view.handle}`)
+      shareUrl(url)
+    }, [track, view])
+
+    const onPressMuteAccount = React.useCallback(async () => {
+      track('ProfileHeader:MuteAccountButtonClicked')
+      try {
+        await view.muteAccount()
+        Toast.show('Account muted')
+      } catch (e: any) {
+        store.log.error('Failed to mute account', e)
+        Toast.show(`There was an issue! ${e.toString()}`)
+      }
+    }, [track, view, store])
+
+    const onPressUnmuteAccount = React.useCallback(async () => {
+      track('ProfileHeader:UnmuteAccountButtonClicked')
+      try {
+        await view.unmuteAccount()
+        Toast.show('Account unmuted')
+      } catch (e: any) {
+        store.log.error('Failed to unmute account', e)
+        Toast.show(`There was an issue! ${e.toString()}`)
+      }
+    }, [track, view, store])
+
+    const onPressBlockAccount = React.useCallback(async () => {
+      track('ProfileHeader:BlockAccountButtonClicked')
+      store.shell.openModal({
+        name: 'confirm',
+        title: 'Block Account',
+        message:
+          'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours.',
+        onPressConfirm: async () => {
+          try {
+            await view.blockAccount()
+            onRefreshAll()
+            Toast.show('Account blocked')
+          } catch (e: any) {
+            store.log.error('Failed to block account', e)
+            Toast.show(`There was an issue! ${e.toString()}`)
+          }
+        },
       })
-      items.push({
-        testID: 'profileHeaderDropdownReportBtn',
-        label: 'Report Account',
-        onPress: onPressReportAccount,
+    }, [track, view, store, onRefreshAll])
+
+    const onPressUnblockAccount = React.useCallback(async () => {
+      track('ProfileHeader:UnblockAccountButtonClicked')
+      store.shell.openModal({
+        name: 'confirm',
+        title: 'Unblock Account',
+        message:
+          'The account will be able to interact with you after unblocking. (You can always block again in the future.)',
+        onPressConfirm: async () => {
+          try {
+            await view.unblockAccount()
+            onRefreshAll()
+            Toast.show('Account unblocked')
+          } catch (e: any) {
+            store.log.error('Failed to block unaccount', e)
+            Toast.show(`There was an issue! ${e.toString()}`)
+          }
+        },
       })
-    }
-    return items
-  }, [
-    isMe,
-    view.viewer.muted,
-    onPressShare,
-    onPressUnmuteAccount,
-    onPressMuteAccount,
-    onPressReportAccount,
-  ])
-  return (
-    <View style={pal.view}>
-      <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
-      <View style={styles.content}>
-        <View style={[styles.buttonsLine]}>
-          {isMe ? (
-            <TouchableOpacity
-              testID="profileHeaderEditProfileButton"
-              onPress={onPressEditProfile}
-              style={[styles.btn, styles.mainBtn, pal.btn]}>
-              <Text type="button" style={pal.text}>
-                Edit Profile
-              </Text>
-            </TouchableOpacity>
-          ) : (
+    }, [track, view, store, onRefreshAll])
+
+    const onPressReportAccount = React.useCallback(() => {
+      track('ProfileHeader:ReportAccountButtonClicked')
+      store.shell.openModal({
+        name: 'report-account',
+        did: view.did,
+      })
+    }, [track, store, view])
+
+    const isMe = React.useMemo(
+      () => store.me.did === view.did,
+      [store.me.did, view.did],
+    )
+    const dropdownItems: DropdownItem[] = React.useMemo(() => {
+      let items: DropdownItem[] = [
+        {
+          testID: 'profileHeaderDropdownShareBtn',
+          label: 'Share',
+          onPress: onPressShare,
+        },
+      ]
+      if (!isMe) {
+        items.push({sep: true})
+        if (!view.viewer.blocking) {
+          items.push({
+            testID: 'profileHeaderDropdownMuteBtn',
+            label: view.viewer.muted ? 'Unmute Account' : 'Mute Account',
+            onPress: view.viewer.muted
+              ? onPressUnmuteAccount
+              : onPressMuteAccount,
+          })
+        }
+        items.push({
+          testID: 'profileHeaderDropdownBlockBtn',
+          label: view.viewer.blocking ? 'Unblock Account' : 'Block Account',
+          onPress: view.viewer.blocking
+            ? onPressUnblockAccount
+            : onPressBlockAccount,
+        })
+        items.push({
+          testID: 'profileHeaderDropdownReportBtn',
+          label: 'Report Account',
+          onPress: onPressReportAccount,
+        })
+      }
+      return items
+    }, [
+      isMe,
+      view.viewer.muted,
+      view.viewer.blocking,
+      onPressShare,
+      onPressUnmuteAccount,
+      onPressMuteAccount,
+      onPressUnblockAccount,
+      onPressBlockAccount,
+      onPressReportAccount,
+    ])
+
+    const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy)
+
+    return (
+      <View style={pal.view}>
+        <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
+        <View style={styles.content}>
+          <View style={[styles.buttonsLine]}>
+            {isMe ? (
+              <TouchableOpacity
+                testID="profileHeaderEditProfileButton"
+                onPress={onPressEditProfile}
+                style={[styles.btn, styles.mainBtn, pal.btn]}>
+                <Text type="button" style={pal.text}>
+                  Edit Profile
+                </Text>
+              </TouchableOpacity>
+            ) : view.viewer.blocking ? (
+              <TouchableOpacity
+                testID="unblockBtn"
+                onPress={onPressUnblockAccount}
+                style={[styles.btn, styles.mainBtn, pal.btn]}>
+                <Text type="button" style={[pal.text, s.bold]}>
+                  Unblock
+                </Text>
+              </TouchableOpacity>
+            ) : !view.viewer.blockedBy ? (
+              <>
+                {store.me.follows.getFollowState(view.did) ===
+                FollowState.Following ? (
+                  <TouchableOpacity
+                    testID="unfollowBtn"
+                    onPress={onPressToggleFollow}
+                    style={[styles.btn, styles.mainBtn, pal.btn]}>
+                    <FontAwesomeIcon
+                      icon="check"
+                      style={[pal.text, s.mr5]}
+                      size={14}
+                    />
+                    <Text type="button" style={pal.text}>
+                      Following
+                    </Text>
+                  </TouchableOpacity>
+                ) : (
+                  <TouchableOpacity
+                    testID="followBtn"
+                    onPress={onPressToggleFollow}
+                    style={[styles.btn, styles.primaryBtn]}>
+                    <FontAwesomeIcon
+                      icon="plus"
+                      style={[s.white as FontAwesomeIconStyle, s.mr5]}
+                    />
+                    <Text type="button" style={[s.white, s.bold]}>
+                      Follow
+                    </Text>
+                  </TouchableOpacity>
+                )}
+              </>
+            ) : null}
+            {dropdownItems?.length ? (
+              <DropdownButton
+                testID="profileHeaderDropdownBtn"
+                type="bare"
+                items={dropdownItems}
+                style={[styles.btn, styles.secondaryBtn, pal.btn]}>
+                <FontAwesomeIcon icon="ellipsis" style={[pal.text]} />
+              </DropdownButton>
+            ) : undefined}
+          </View>
+          <View>
+            <Text
+              testID="profileHeaderDisplayName"
+              type="title-2xl"
+              style={[pal.text, styles.title]}>
+              {sanitizeDisplayName(view.displayName || view.handle)}
+            </Text>
+          </View>
+          <View style={styles.handleLine}>
+            {view.viewer.followedBy && !blockHide ? (
+              <View style={[styles.pill, pal.btn, s.mr5]}>
+                <Text type="xs" style={[pal.text]}>
+                  Follows you
+                </Text>
+              </View>
+            ) : undefined}
+            <Text style={pal.textLight}>@{view.handle}</Text>
+          </View>
+          {!blockHide && (
             <>
-              {store.me.follows.getFollowState(view.did) ===
-              FollowState.Following ? (
+              <View style={styles.metricsLine}>
                 <TouchableOpacity
-                  testID="unfollowBtn"
-                  onPress={onPressToggleFollow}
-                  style={[styles.btn, styles.mainBtn, pal.btn]}>
-                  <FontAwesomeIcon
-                    icon="check"
-                    style={[pal.text, s.mr5]}
-                    size={14}
-                  />
-                  <Text type="button" style={pal.text}>
-                    Following
+                  testID="profileHeaderFollowersButton"
+                  style={[s.flexRow, s.mr10]}
+                  onPress={onPressFollowers}>
+                  <Text type="md" style={[s.bold, s.mr2, pal.text]}>
+                    {view.followersCount}
+                  </Text>
+                  <Text type="md" style={[pal.textLight]}>
+                    {pluralize(view.followersCount, 'follower')}
                   </Text>
                 </TouchableOpacity>
-              ) : (
                 <TouchableOpacity
-                  testID="followBtn"
-                  onPress={onPressToggleFollow}
-                  style={[styles.btn, styles.primaryBtn]}>
-                  <FontAwesomeIcon
-                    icon="plus"
-                    style={[s.white as FontAwesomeIconStyle, s.mr5]}
-                  />
-                  <Text type="button" style={[s.white, s.bold]}>
-                    Follow
+                  testID="profileHeaderFollowsButton"
+                  style={[s.flexRow, s.mr10]}
+                  onPress={onPressFollows}>
+                  <Text type="md" style={[s.bold, s.mr2, pal.text]}>
+                    {view.followsCount}
+                  </Text>
+                  <Text type="md" style={[pal.textLight]}>
+                    following
                   </Text>
                 </TouchableOpacity>
-              )}
+                <View style={[s.flexRow, s.mr10]}>
+                  <Text type="md" style={[s.bold, s.mr2, pal.text]}>
+                    {view.postsCount}
+                  </Text>
+                  <Text type="md" style={[pal.textLight]}>
+                    {pluralize(view.postsCount, 'post')}
+                  </Text>
+                </View>
+              </View>
+              {view.descriptionRichText ? (
+                <RichText
+                  testID="profileHeaderDescription"
+                  style={[styles.description, pal.text]}
+                  numberOfLines={15}
+                  richText={view.descriptionRichText}
+                />
+              ) : undefined}
             </>
           )}
-          {dropdownItems?.length ? (
-            <DropdownButton
-              testID="profileHeaderDropdownBtn"
-              type="bare"
-              items={dropdownItems}
-              style={[styles.btn, styles.secondaryBtn, pal.btn]}>
-              <FontAwesomeIcon icon="ellipsis" style={[pal.text]} />
-            </DropdownButton>
-          ) : undefined}
-        </View>
-        <View>
-          <Text
-            testID="profileHeaderDisplayName"
-            type="title-2xl"
-            style={[pal.text, styles.title]}>
-            {sanitizeDisplayName(view.displayName || view.handle)}
-          </Text>
-        </View>
-        <View style={styles.handleLine}>
-          {view.viewer.followedBy ? (
-            <View style={[styles.pill, pal.btn, s.mr5]}>
-              <Text type="xs" style={[pal.text]}>
-                Follows you
-              </Text>
-            </View>
-          ) : undefined}
-          <Text style={pal.textLight}>@{view.handle}</Text>
-        </View>
-        <View style={styles.metricsLine}>
-          <TouchableOpacity
-            testID="profileHeaderFollowersButton"
-            style={[s.flexRow, s.mr10]}
-            onPress={onPressFollowers}>
-            <Text type="md" style={[s.bold, s.mr2, pal.text]}>
-              {view.followersCount}
-            </Text>
-            <Text type="md" style={[pal.textLight]}>
-              {pluralize(view.followersCount, 'follower')}
-            </Text>
-          </TouchableOpacity>
-          <TouchableOpacity
-            testID="profileHeaderFollowsButton"
-            style={[s.flexRow, s.mr10]}
-            onPress={onPressFollows}>
-            <Text type="md" style={[s.bold, s.mr2, pal.text]}>
-              {view.followsCount}
-            </Text>
-            <Text type="md" style={[pal.textLight]}>
-              following
-            </Text>
-          </TouchableOpacity>
-          <View style={[s.flexRow, s.mr10]}>
-            <Text type="md" style={[s.bold, s.mr2, pal.text]}>
-              {view.postsCount}
-            </Text>
-            <Text type="md" style={[pal.textLight]}>
-              {pluralize(view.postsCount, 'post')}
-            </Text>
+          <ProfileHeaderWarnings moderation={view.moderation.view} />
+          <View style={styles.moderationLines}>
+            {view.viewer.blocking ? (
+              <View
+                testID="profileHeaderBlockedNotice"
+                style={[styles.moderationNotice, pal.view, pal.border]}>
+                <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
+                <Text type="md" style={[s.mr2, pal.text]}>
+                  Account blocked
+                </Text>
+              </View>
+            ) : view.viewer.muted ? (
+              <View
+                testID="profileHeaderMutedNotice"
+                style={[styles.moderationNotice, pal.view, pal.border]}>
+                <FontAwesomeIcon
+                  icon={['far', 'eye-slash']}
+                  style={[pal.text, s.mr5]}
+                />
+                <Text type="md" style={[s.mr2, pal.text]}>
+                  Account muted
+                </Text>
+              </View>
+            ) : undefined}
+            {view.viewer.blockedBy && (
+              <View
+                testID="profileHeaderBlockedNotice"
+                style={[styles.moderationNotice, pal.view, pal.border]}>
+                <FontAwesomeIcon icon="ban" style={[pal.text, s.mr5]} />
+                <Text type="md" style={[s.mr2, pal.text]}>
+                  This account has blocked you
+                </Text>
+              </View>
+            )}
           </View>
         </View>
-        {view.descriptionRichText ? (
-          <RichText
-            testID="profileHeaderDescription"
-            style={[styles.description, pal.text]}
-            numberOfLines={15}
-            richText={view.descriptionRichText}
-          />
-        ) : undefined}
-        <ProfileHeaderWarnings moderation={view.moderation.view} />
-        {view.viewer.muted ? (
+        {!isDesktopWeb && !hideBackButton && (
+          <TouchableWithoutFeedback
+            onPress={onPressBack}
+            hitSlop={BACK_HITSLOP}>
+            <View style={styles.backBtnWrapper}>
+              <BlurView style={styles.backBtn} blurType="dark">
+                <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
+              </BlurView>
+            </View>
+          </TouchableWithoutFeedback>
+        )}
+        <TouchableWithoutFeedback
+          testID="profileHeaderAviButton"
+          onPress={onPressAvi}>
           <View
-            testID="profileHeaderMutedNotice"
-            style={[styles.detailLine, pal.btn, s.p5]}>
-            <FontAwesomeIcon
-              icon={['far', 'eye-slash']}
-              style={[pal.text, s.mr5]}
+            style={[
+              pal.view,
+              {borderColor: pal.colors.background},
+              styles.avi,
+            ]}>
+            <UserAvatar
+              size={80}
+              avatar={view.avatar}
+              moderation={view.moderation.avatar}
             />
-            <Text type="md" style={[s.mr2, pal.text]}>
-              Account muted
-            </Text>
-          </View>
-        ) : undefined}
-      </View>
-      {!isDesktopWeb && !hideBackButton && (
-        <TouchableWithoutFeedback onPress={onPressBack} hitSlop={BACK_HITSLOP}>
-          <View style={styles.backBtnWrapper}>
-            <BlurView style={styles.backBtn} blurType="dark">
-              <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
-            </BlurView>
           </View>
         </TouchableWithoutFeedback>
-      )}
-      <TouchableWithoutFeedback
-        testID="profileHeaderAviButton"
-        onPress={onPressAvi}>
-        <View
-          style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-          <UserAvatar
-            size={80}
-            avatar={view.avatar}
-            moderation={view.moderation.avatar}
-          />
-        </View>
-      </TouchableWithoutFeedback>
-    </View>
-  )
-})
+      </View>
+    )
+  },
+)
 
 const styles = StyleSheet.create({
   banner: {
@@ -460,6 +556,19 @@ const styles = StyleSheet.create({
     paddingVertical: 2,
   },
 
+  moderationLines: {
+    gap: 6,
+  },
+
+  moderationNotice: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    borderWidth: 1,
+    borderRadius: 8,
+    paddingHorizontal: 12,
+    paddingVertical: 10,
+  },
+
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
 })
diff --git a/src/view/index.ts b/src/view/index.ts
index 93c6fccc5..8de035868 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -15,6 +15,7 @@ import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotate
 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
 import {faAt} from '@fortawesome/free-solid-svg-icons/faAt'
 import {faBars} from '@fortawesome/free-solid-svg-icons/faBars'
+import {faBan} from '@fortawesome/free-solid-svg-icons/faBan'
 import {faBell} from '@fortawesome/free-solid-svg-icons/faBell'
 import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
 import {faBookmark} from '@fortawesome/free-solid-svg-icons/faBookmark'
@@ -90,6 +91,7 @@ export function setup() {
     faArrowRotateLeft,
     faArrowsRotate,
     faAt,
+    faBan,
     faBars,
     faBell,
     farBell,
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index f957a45e0..4e20558b7 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -27,7 +27,7 @@ export const AppPasswords = withAuthRequired(
 
     useFocusEffect(
       React.useCallback(() => {
-        screen('Settings')
+        screen('AppPasswords')
         store.shell.setMinimalShellMode(false)
       }, [screen, store]),
     )
diff --git a/src/view/screens/BlockedAccounts.tsx b/src/view/screens/BlockedAccounts.tsx
new file mode 100644
index 000000000..195068510
--- /dev/null
+++ b/src/view/screens/BlockedAccounts.tsx
@@ -0,0 +1,172 @@
+import React, {useMemo} from 'react'
+import {
+  ActivityIndicator,
+  FlatList,
+  RefreshControl,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
+import {Text} from '../com/util/text/Text'
+import {useStores} from 'state/index'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {observer} from 'mobx-react-lite'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts'
+import {useAnalytics} from 'lib/analytics'
+import {useFocusEffect} from '@react-navigation/native'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {ProfileCard} from 'view/com/profile/ProfileCard'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'BlockedAccounts'>
+export const BlockedAccounts = withAuthRequired(
+  observer(({}: Props) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {screen} = useAnalytics()
+    const blockedAccounts = useMemo(
+      () => new BlockedAccountsModel(store),
+      [store],
+    )
+
+    useFocusEffect(
+      React.useCallback(() => {
+        screen('BlockedAccounts')
+        store.shell.setMinimalShellMode(false)
+        blockedAccounts.refresh()
+      }, [screen, store, blockedAccounts]),
+    )
+
+    const onRefresh = React.useCallback(() => {
+      blockedAccounts.refresh()
+    }, [blockedAccounts])
+    const onEndReached = React.useCallback(() => {
+      blockedAccounts
+        .loadMore()
+        .catch(err =>
+          store.log.error('Failed to load more blocked accounts', err),
+        )
+    }, [blockedAccounts, store])
+
+    const renderItem = ({
+      item,
+      index,
+    }: {
+      item: ActorDefs.ProfileView
+      index: number
+    }) => (
+      <ProfileCard
+        testID={`blockedAccount-${index}`}
+        key={item.did}
+        profile={item}
+        overrideModeration
+      />
+    )
+    return (
+      <CenteredView
+        style={[
+          styles.container,
+          isDesktopWeb && styles.containerDesktop,
+          pal.view,
+          pal.border,
+        ]}
+        testID="blockedAccountsScreen">
+        <ViewHeader title="Blocked Accounts" showOnDesktop />
+        <Text
+          type="sm"
+          style={[
+            styles.description,
+            pal.text,
+            isDesktopWeb && styles.descriptionDesktop,
+          ]}>
+          Blocked accounts cannot reply in your threads, mention you, or
+          otherwise interact with you. You will not see their content and they
+          will be prevented from seeing yours.
+        </Text>
+        {!blockedAccounts.hasContent ? (
+          <View style={[pal.border, !isDesktopWeb && styles.flex1]}>
+            <View style={[styles.empty, pal.viewLight]}>
+              <Text type="lg" style={[pal.text, styles.emptyText]}>
+                You have not blocked any accounts yet. To block an account, go
+                to their profile and selected "Block account" from the menu on
+                their account.
+              </Text>
+            </View>
+          </View>
+        ) : (
+          <FlatList
+            style={[!isDesktopWeb && styles.flex1]}
+            data={blockedAccounts.blocks}
+            keyExtractor={(item: ActorDefs.ProfileView) => item.did}
+            refreshControl={
+              <RefreshControl
+                refreshing={blockedAccounts.isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+              />
+            }
+            onEndReached={onEndReached}
+            renderItem={renderItem}
+            initialNumToRender={15}
+            ListFooterComponent={() => (
+              <View style={styles.footer}>
+                {blockedAccounts.isLoading && <ActivityIndicator />}
+              </View>
+            )}
+            extraData={blockedAccounts.isLoading}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </CenteredView>
+    )
+  }),
+)
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 100,
+  },
+  containerDesktop: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  title: {
+    textAlign: 'center',
+    marginTop: 12,
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 30,
+    marginBottom: 14,
+  },
+  descriptionDesktop: {
+    marginTop: 14,
+  },
+
+  flex1: {
+    flex: 1,
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+  emptyText: {
+    textAlign: 'center',
+  },
+
+  footer: {
+    height: 200,
+    paddingTop: 20,
+  },
+})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 4be117932..5fb212554 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -116,6 +116,24 @@ export const ProfileScreen = withAuthRequired(
         } 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
@@ -137,7 +155,12 @@ export const ProfileScreen = withAuthRequired(
         }
         return <View />
       },
-      [onPressTryAgain, uiState.profile.did],
+      [
+        onPressTryAgain,
+        uiState.profile.did,
+        uiState.feed.isBlocking,
+        uiState.feed.isBlockedBy,
+      ],
     )
 
     return (
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 89e2d78b4..ef02e8189 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -255,7 +255,7 @@ export const SettingsScreen = withAuthRequired(
           <View style={styles.spacer20} />
 
           <Text type="xl-bold" style={[pal.text, styles.heading]}>
-            Advanced
+            Moderation
           </Text>
           <TouchableOpacity
             testID="contentFilteringBtn"
@@ -272,6 +272,26 @@ export const SettingsScreen = withAuthRequired(
             </Text>
           </TouchableOpacity>
           <Link
+            testID="blockedAccountsBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            href="/settings/blocked-accounts">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon="ban"
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Blocked accounts
+            </Text>
+          </Link>
+
+          <View style={styles.spacer20} />
+
+          <Text type="xl-bold" style={[pal.text, styles.heading]}>
+            Advanced
+          </Text>
+          <Link
             testID="appPasswordBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             href="/settings/app-passwords">