about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/lib/labeling/helpers.ts94
-rw-r--r--src/lib/labeling/types.ts4
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/models/content/post-thread.ts43
-rw-r--r--src/state/models/content/profile.ts32
-rw-r--r--src/state/models/feeds/notifications.ts4
-rw-r--r--src/state/models/feeds/posts.ts25
-rw-r--r--src/state/models/lists/blocked-accounts.ts106
-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
21 files changed, 965 insertions, 283 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index d5ffb1539..3a9392fb8 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -27,6 +27,8 @@ import {colors} from 'lib/styles'
 import {isNative} from 'platform/detection'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {router} from './routes'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from './state'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
@@ -46,9 +48,8 @@ import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
 import {TermsOfServiceScreen} from './view/screens/TermsOfService'
 import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
 import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from './state'
 import {AppPasswords} from 'view/screens/AppPasswords'
+import {BlockedAccounts} from 'view/screens/BlockedAccounts'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -88,6 +89,7 @@ function commonScreens(Stack: typeof HomeTab) {
       />
       <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
       <Stack.Screen name="AppPasswords" component={AppPasswords} />
+      <Stack.Screen name="BlockedAccounts" component={BlockedAccounts} />
     </>
   )
 }
diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts
index 0092b99e4..5ec591cfb 100644
--- a/src/lib/labeling/helpers.ts
+++ b/src/lib/labeling/helpers.ts
@@ -57,6 +57,7 @@ export function getPostModeration(
   let avatar = {
     warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
     blur:
+      postInfo.isBlocking ||
       accountPref.pref === 'hide' ||
       accountPref.pref === 'warn' ||
       profilePref.pref === 'hide' ||
@@ -75,6 +76,22 @@ export function getPostModeration(
   }
 
   // hide cases
+  if (postInfo.isBlocking) {
+    return {
+      avatar,
+      list: hide('Post from an account you blocked.'),
+      thread: hide('Post from an account you blocked.'),
+      view: warn('Post from an account you blocked.'),
+    }
+  }
+  if (postInfo.isBlockedBy) {
+    return {
+      avatar,
+      list: hide('Post from an account that has blocked you.'),
+      thread: hide('Post from an account that has blocked you.'),
+      view: warn('Post from an account that has blocked you.'),
+    }
+  }
   if (accountPref.pref === 'hide') {
     return {
       avatar,
@@ -144,21 +161,45 @@ export function getPostModeration(
   }
 }
 
+export function mergePostModerations(
+  moderations: PostModeration[],
+): PostModeration {
+  const merged: PostModeration = {
+    avatar: {warn: false, blur: false},
+    list: show(),
+    thread: show(),
+    view: show(),
+  }
+  for (const mod of moderations) {
+    if (mod.list.behavior === ModerationBehaviorCode.Hide) {
+      merged.list = mod.list
+    }
+    if (mod.thread.behavior === ModerationBehaviorCode.Hide) {
+      merged.thread = mod.thread
+    }
+    if (mod.view.behavior === ModerationBehaviorCode.Hide) {
+      merged.view = mod.view
+    }
+  }
+  return merged
+}
+
 export function getProfileModeration(
   store: RootStoreModel,
-  profileLabels: ProfileLabelInfo,
+  profileInfo: ProfileLabelInfo,
 ): ProfileModeration {
   const accountPref = store.preferences.getLabelPreference(
-    profileLabels.accountLabels,
+    profileInfo.accountLabels,
   )
   const profilePref = store.preferences.getLabelPreference(
-    profileLabels.profileLabels,
+    profileInfo.profileLabels,
   )
 
   // avatar
   let avatar = {
     warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
     blur:
+      profileInfo.isBlocking ||
       accountPref.pref === 'hide' ||
       accountPref.pref === 'warn' ||
       profilePref.pref === 'hide' ||
@@ -193,7 +234,10 @@ export function getProfileModeration(
   if (accountPref.pref === 'warn') {
     return {
       avatar,
-      list: warn(accountPref.desc.warning),
+      list:
+        profileInfo.isBlocking || profileInfo.isBlockedBy
+          ? hide('Blocked account')
+          : warn(accountPref.desc.warning),
       view: warn(accountPref.desc.warning),
     }
   }
@@ -208,7 +252,7 @@ export function getProfileModeration(
 
   return {
     avatar,
-    list: show(),
+    list: profileInfo.isBlocking ? hide('Blocked account') : show(),
     view: show(),
   }
 }
@@ -220,6 +264,7 @@ export function getProfileViewBasicLabelInfo(
     accountLabels: filterAccountLabels(profile.labels),
     profileLabels: filterProfileLabels(profile.labels),
     isMuted: profile.viewer?.muted || false,
+    isBlocking: !!profile.viewer?.blocking || false,
   }
 }
 
@@ -236,6 +281,45 @@ export function getEmbedLabels(embed?: Embed): Label[] {
   return []
 }
 
+export function getEmbedMuted(embed?: Embed): boolean {
+  if (!embed) {
+    return false
+  }
+  if (
+    AppBskyEmbedRecord.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record)
+  ) {
+    return !!embed.record.author.viewer?.muted
+  }
+  return false
+}
+
+export function getEmbedBlocking(embed?: Embed): boolean {
+  if (!embed) {
+    return false
+  }
+  if (
+    AppBskyEmbedRecord.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record)
+  ) {
+    return !!embed.record.author.viewer?.blocking
+  }
+  return false
+}
+
+export function getEmbedBlockedBy(embed?: Embed): boolean {
+  if (!embed) {
+    return false
+  }
+  if (
+    AppBskyEmbedRecord.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record)
+  ) {
+    return !!embed.record.author.viewer?.blockedBy
+  }
+  return false
+}
+
 export function filterAccountLabels(labels?: Label[]): Label[] {
   if (!labels) {
     return []
diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts
index d4efb499a..20ecaa5b5 100644
--- a/src/lib/labeling/types.ts
+++ b/src/lib/labeling/types.ts
@@ -17,12 +17,16 @@ export interface PostLabelInfo {
   accountLabels: Label[]
   profileLabels: Label[]
   isMuted: boolean
+  isBlocking: boolean
+  isBlockedBy: boolean
 }
 
 export interface ProfileLabelInfo {
   accountLabels: Label[]
   profileLabels: Label[]
   isMuted: boolean
+  isBlocking: boolean
+  isBlockedBy: boolean
 }
 
 export enum ModerationBehaviorCode {
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index eeb97ba7a..3aff82117 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -20,6 +20,7 @@ export type CommonNavigatorParams = {
   CommunityGuidelines: undefined
   CopyrightPolicy: undefined
   AppPasswords: undefined
+  BlockedAccounts: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/routes.ts b/src/routes.ts
index 6762cde9d..15595775e 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -14,6 +14,7 @@ export const router = new Router({
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
+  BlockedAccounts: '/settings/blocked-accounts',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
   TermsOfService: '/support/tos',
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 8f9a55032..18a42732c 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -13,6 +13,9 @@ import {updateDataOptimistically} from 'lib/async/revertible'
 import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
+  getEmbedMuted,
+  getEmbedBlocking,
+  getEmbedBlockedBy,
   filterAccountLabels,
   filterProfileLabels,
   getPostModeration,
@@ -30,7 +33,10 @@ export class PostThreadItemModel {
   // data
   post: AppBskyFeedDefs.PostView
   postRecord?: FeedPost.Record
-  parent?: PostThreadItemModel | AppBskyFeedDefs.NotFoundPost
+  parent?:
+    | PostThreadItemModel
+    | AppBskyFeedDefs.NotFoundPost
+    | AppBskyFeedDefs.BlockedPost
   replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[]
   richText?: RichText
 
@@ -60,7 +66,18 @@ export class PostThreadItemModel {
       ),
       accountLabels: filterAccountLabels(this.post.author.labels),
       profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted: this.post.author.viewer?.muted || false,
+      isMuted:
+        this.post.author.viewer?.muted ||
+        getEmbedMuted(this.post.embed) ||
+        false,
+      isBlocking:
+        !!this.post.author.viewer?.blocking ||
+        getEmbedBlocking(this.post.embed) ||
+        false,
+      isBlockedBy:
+        !!this.post.author.viewer?.blockedBy ||
+        getEmbedBlockedBy(this.post.embed) ||
+        false,
     }
   }
 
@@ -114,6 +131,8 @@ export class PostThreadItemModel {
         this.parent = parentModel
       } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) {
         this.parent = v.parent
+      } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) {
+        this.parent = v.parent
       }
     }
     // replies
@@ -218,6 +237,7 @@ export class PostThreadModel {
 
   // data
   thread?: PostThreadItemModel
+  isBlocked = false
 
   constructor(
     public rootStore: RootStoreModel,
@@ -377,11 +397,17 @@ export class PostThreadModel {
       this._replaceAll(res)
       this._xIdle()
     } catch (e: any) {
+      console.log(e)
       this._xIdle(e)
     }
   }
 
   _replaceAll(res: GetPostThread.Response) {
+    this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread)
+    if (this.isBlocked) {
+      return
+    }
+    pruneReplies(res.data.thread)
     sortThread(res.data.thread)
     const thread = new PostThreadItemModel(
       this.rootStore,
@@ -399,7 +425,20 @@ export class PostThreadModel {
 type MaybePost =
   | AppBskyFeedDefs.ThreadViewPost
   | AppBskyFeedDefs.NotFoundPost
+  | AppBskyFeedDefs.BlockedPost
   | {[k: string]: unknown; $type: string}
+function pruneReplies(post: MaybePost) {
+  if (post.replies) {
+    post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => {
+      if (reply.blocked) {
+        return false
+      }
+      pruneReplies(reply)
+      return true
+    })
+  }
+}
+
 function sortThread(post: MaybePost) {
   if (post.notFound) {
     return
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index ea75d19c6..dddf488a3 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -1,5 +1,6 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {
+  AtUri,
   ComAtprotoLabelDefs,
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
@@ -23,6 +24,8 @@ export class ProfileViewerModel {
   muted?: boolean
   following?: string
   followedBy?: string
+  blockedBy?: boolean
+  blocking?: string
 
   constructor() {
     makeAutoObservable(this)
@@ -86,6 +89,8 @@ export class ProfileModel {
       accountLabels: filterAccountLabels(this.labels),
       profileLabels: filterProfileLabels(this.labels),
       isMuted: this.viewer?.muted || false,
+      isBlocking: !!this.viewer?.blocking || false,
+      isBlockedBy: !!this.viewer?.blockedBy || false,
     }
   }
 
@@ -185,6 +190,33 @@ export class ProfileModel {
     await this.refresh()
   }
 
+  async blockAccount() {
+    const res = await this.rootStore.agent.app.bsky.graph.block.create(
+      {
+        repo: this.rootStore.me.did,
+      },
+      {
+        subject: this.did,
+        createdAt: new Date().toISOString(),
+      },
+    )
+    this.viewer.blocking = res.uri
+    await this.refresh()
+  }
+
+  async unblockAccount() {
+    if (!this.viewer.blocking) {
+      return
+    }
+    const {rkey} = new AtUri(this.viewer.blocking)
+    await this.rootStore.agent.app.bsky.graph.block.delete({
+      repo: this.rootStore.me.did,
+      rkey,
+    })
+    this.viewer.blocking = undefined
+    await this.refresh()
+  }
+
   // state transitions
   // =
 
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 02f58819f..3ffd10b99 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -111,6 +111,10 @@ export class NotificationsFeedItemModel {
         addedInfo?.profileLabels || [],
       ),
       isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
+      isBlocking:
+        !!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
+      isBlockedBy:
+        !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false,
     }
   }
 
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 62c6da3de..62047acba 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -23,7 +23,11 @@ import {updateDataOptimistically} from 'lib/async/revertible'
 import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {
   getEmbedLabels,
+  getEmbedMuted,
+  getEmbedBlocking,
+  getEmbedBlockedBy,
   getPostModeration,
+  mergePostModerations,
   filterAccountLabels,
   filterProfileLabels,
 } from 'lib/labeling/helpers'
@@ -97,7 +101,18 @@ export class PostsFeedItemModel {
       ),
       accountLabels: filterAccountLabels(this.post.author.labels),
       profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted: this.post.author.viewer?.muted || false,
+      isMuted:
+        this.post.author.viewer?.muted ||
+        getEmbedMuted(this.post.embed) ||
+        false,
+      isBlocking:
+        !!this.post.author.viewer?.blocking ||
+        getEmbedBlocking(this.post.embed) ||
+        false,
+      isBlockedBy:
+        !!this.post.author.viewer?.blockedBy ||
+        getEmbedBlockedBy(this.post.embed) ||
+        false,
     }
   }
 
@@ -240,6 +255,10 @@ export class PostsFeedSliceModel {
     return this.items[0]
   }
 
+  get moderation() {
+    return mergePostModerations(this.items.map(item => item.moderation))
+  }
+
   containsUri(uri: string) {
     return !!this.items.find(item => item.post.uri === uri)
   }
@@ -265,6 +284,8 @@ export class PostsFeedModel {
   isRefreshing = false
   hasNewLatest = false
   hasLoaded = false
+  isBlocking = false
+  isBlockedBy = false
   error = ''
   loadMoreError = ''
   params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
@@ -553,6 +574,8 @@ export class PostsFeedModel {
     this.isLoading = false
     this.isRefreshing = false
     this.hasLoaded = true
+    this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError
+    this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError
     this.error = cleanError(error)
     this.loadMoreError = cleanError(loadMoreError)
     if (error) {
diff --git a/src/state/models/lists/blocked-accounts.ts b/src/state/models/lists/blocked-accounts.ts
new file mode 100644
index 000000000..20eef8aff
--- /dev/null
+++ b/src/state/models/lists/blocked-accounts.ts
@@ -0,0 +1,106 @@
+import {makeAutoObservable} from 'mobx'
+import {
+  AppBskyGraphGetBlocks as GetBlocks,
+  AppBskyActorDefs as ActorDefs,
+} from '@atproto/api'
+import {RootStoreModel} from '../root-store'
+import {cleanError} from 'lib/strings/errors'
+import {bundleAsync} from 'lib/async/bundle'
+
+const PAGE_SIZE = 30
+
+export class BlockedAccountsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  blocks: ActorDefs.ProfileView[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.blocks.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.graph.getBlocks({
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user followers', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetBlocks.Response) {
+    this.blocks = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetBlocks.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    this.blocks = this.blocks.concat(res.data.blocks)
+  }
+}
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">