about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-08-03 22:08:30 -0700
committerGitHub <noreply@github.com>2023-08-03 22:08:30 -0700
commitb154d3ea21bcca48594aa397420b0f6718dbf7f3 (patch)
tree31d51cd32cfeabc78d647cc068f5f84551f7a1b0
parent3ae5a6b63134f6aa57eba4fba18e4ad136623e20 (diff)
downloadvoidsky-b154d3ea21bcca48594aa397420b0f6718dbf7f3.tar.zst
Labeling & moderation updates [DRAFT] (#1057)
* First pass moving to the new labeling sdk (it compiles)

* Correct behaviors around interpreting label moderation

* Improve moderation state rendering

* Improve hiders and alerts

* Improve handling of mutes

* Improve profile warnings

* Add profile blurring to profile header

* Add blocks to test cases

* Render labels on profile cards, do not filter

* Filter profiles from suggestions using moderation

* Apply profile blurring to ProfileCard

* Handle blocked and deleted quote posts

* Temporarily translate content filtering settings to new labels

* Fix types

* Tune ContentHider & PostHider click targets

* Put a warning on profilecard label pills

* Fix screenhider learnmore link on mobile

* Enforce no-override on user avatar

* Dont enumerate profile blur-media labels in alerts

* Fixes to muted posts (esp quotes of muted users)

* Fixes to account/profile warnings

* Bump @atproto/api@0.5.0

* Bump @atproto/api@0.5.1

* Fix tests

* 1.43

* Remove log

* Bump @atproto/api@0.5.2
-rw-r--r--__e2e__/mock-server.ts211
-rw-r--r--__e2e__/tests/profile-screen.test.ts8
-rw-r--r--app.json4
-rw-r--r--jest/test-pds.ts3
-rw-r--r--package.json4
-rw-r--r--src/lib/api/hack-add-deleted-embed.ts24
-rw-r--r--src/lib/moderation.ts77
-rw-r--r--src/lib/strings/display-names.ts11
-rw-r--r--src/state/models/content/post-thread-item.ts6
-rw-r--r--src/state/models/content/post-thread.ts56
-rw-r--r--src/state/models/content/profile.ts26
-rw-r--r--src/state/models/discovery/foafs.ts8
-rw-r--r--src/state/models/discovery/suggested-actors.ts9
-rw-r--r--src/state/models/feeds/notifications.ts47
-rw-r--r--src/state/models/feeds/post.ts42
-rw-r--r--src/state/models/feeds/posts-slice.ts16
-rw-r--r--src/state/models/ui/preferences.ts55
-rw-r--r--src/state/models/ui/shell.ts9
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/ModerationDetails.tsx101
-rw-r--r--src/view/com/notifications/FeedItem.tsx22
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx90
-rw-r--r--src/view/com/post/Post.tsx31
-rw-r--r--src/view/com/posts/FeedItem.tsx48
-rw-r--r--src/view/com/posts/FeedSlice.tsx99
-rw-r--r--src/view/com/profile/ProfileCard.tsx93
-rw-r--r--src/view/com/profile/ProfileHeader.tsx74
-rw-r--r--src/view/com/util/UserAvatar.tsx22
-rw-r--r--src/view/com/util/UserBanner.tsx4
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx120
-rw-r--r--src/view/com/util/moderation/ImageHider.tsx80
-rw-r--r--src/view/com/util/moderation/PostAlerts.tsx68
-rw-r--r--src/view/com/util/moderation/PostHider.tsx131
-rw-r--r--src/view/com/util/moderation/ProfileHeaderAlerts.tsx76
-rw-r--r--src/view/com/util/moderation/ProfileHeaderWarnings.tsx44
-rw-r--r--src/view/com/util/moderation/ScreenHider.tsx57
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx68
-rw-r--r--src/view/com/util/post-embeds/index.tsx41
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx1
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx1
-rw-r--r--src/view/screens/Profile.tsx4
-rw-r--r--yarn.lock8
43 files changed, 1191 insertions, 715 deletions
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts
index f553fc686..79c318571 100644
--- a/__e2e__/mock-server.ts
+++ b/__e2e__/mock-server.ts
@@ -75,26 +75,35 @@ async function main() {
           )
 
           for (const user of [
-            'csam-account',
-            'csam-profile',
-            'csam-posts',
+            'dmca-account',
+            'dmca-profile',
+            'dmca-posts',
             'porn-account',
             'porn-profile',
             'porn-posts',
             'nudity-account',
             'nudity-profile',
             'nudity-posts',
+            'scam-account',
+            'scam-profile',
+            'scam-posts',
             'unknown-account',
             'unknown-profile',
             'unknown-posts',
-            'always-filter-account',
-            'always-filter-profile',
-            'always-filter-posts',
-            'always-warn-account',
-            'always-warn-profile',
-            'always-warn-posts',
+            'hide-account',
+            'hide-profile',
+            'hide-posts',
+            'no-promote-account',
+            'no-promote-profile',
+            'no-promote-posts',
+            'warn-account',
+            'warn-profile',
+            'warn-posts',
             'muted-account',
             'muted-by-list-account',
+            'blocking-account',
+            'blockedby-account',
+            'mutual-block-account',
           ]) {
             await server.mocker.createUser(user)
             await server.mocker.follow('alice', user)
@@ -108,25 +117,25 @@ async function main() {
             await server.mocker.like(user, anchorPost)
           }
 
-          await server.mocker.labelAccount('csam', 'csam-account')
-          await server.mocker.labelProfile('csam', 'csam-profile')
+          await server.mocker.labelAccount('dmca-violation', 'dmca-account')
+          await server.mocker.labelProfile('dmca-violation', 'dmca-profile')
           await server.mocker.labelPost(
-            'csam',
-            await server.mocker.createPost('csam-posts', 'csam post'),
+            'dmca-violation',
+            await server.mocker.createPost('dmca-posts', 'dmca post'),
           )
           await server.mocker.labelPost(
-            'csam',
+            'dmca-violation',
             await server.mocker.createQuotePost(
-              'csam-posts',
-              'csam quote post',
+              'dmca-posts',
+              'dmca quote post',
               anchorPost,
             ),
           )
           await server.mocker.labelPost(
-            'csam',
+            'dmca-violation',
             await server.mocker.createReply(
-              'csam-posts',
-              'csam reply',
+              'dmca-posts',
+              'dmca reply',
               anchorPost,
             ),
           )
@@ -177,6 +186,29 @@ async function main() {
             ),
           )
 
+          await server.mocker.labelAccount('scam', 'scam-account')
+          await server.mocker.labelProfile('scam', 'scam-profile')
+          await server.mocker.labelPost(
+            'scam',
+            await server.mocker.createPost('scam-posts', 'scam post'),
+          )
+          await server.mocker.labelPost(
+            'scam',
+            await server.mocker.createQuotePost(
+              'scam-posts',
+              'scam quote post',
+              anchorPost,
+            ),
+          )
+          await server.mocker.labelPost(
+            'scam',
+            await server.mocker.createReply(
+              'scam-posts',
+              'scam reply',
+              anchorPost,
+            ),
+          )
+
           await server.mocker.labelAccount(
             'not-a-real-label',
             'unknown-account',
@@ -206,54 +238,74 @@ async function main() {
             ),
           )
 
-          await server.mocker.labelAccount('!filter', 'always-filter-account')
-          await server.mocker.labelProfile('!filter', 'always-filter-profile')
+          await server.mocker.labelAccount('!hide', 'hide-account')
+          await server.mocker.labelProfile('!hide', 'hide-profile')
+          await server.mocker.labelPost(
+            '!hide',
+            await server.mocker.createPost('hide-posts', 'hide post'),
+          )
           await server.mocker.labelPost(
-            '!filter',
+            '!hide',
+            await server.mocker.createQuotePost(
+              'hide-posts',
+              'hide quote post',
+              anchorPost,
+            ),
+          )
+          await server.mocker.labelPost(
+            '!hide',
+            await server.mocker.createReply(
+              'hide-posts',
+              'hide reply',
+              anchorPost,
+            ),
+          )
+
+          await server.mocker.labelAccount('!no-promote', 'no-promote-account')
+          await server.mocker.labelProfile('!no-promote', 'no-promote-profile')
+          await server.mocker.labelPost(
+            '!no-promote',
             await server.mocker.createPost(
-              'always-filter-posts',
-              'always-filter post',
+              'no-promote-posts',
+              'no-promote post',
             ),
           )
           await server.mocker.labelPost(
-            '!filter',
+            '!no-promote',
             await server.mocker.createQuotePost(
-              'always-filter-posts',
-              'always-filter quote post',
+              'no-promote-posts',
+              'no-promote quote post',
               anchorPost,
             ),
           )
           await server.mocker.labelPost(
-            '!filter',
+            '!no-promote',
             await server.mocker.createReply(
-              'always-filter-posts',
-              'always-filter reply',
+              'no-promote-posts',
+              'no-promote reply',
               anchorPost,
             ),
           )
 
-          await server.mocker.labelAccount('!warn', 'always-warn-account')
-          await server.mocker.labelProfile('!warn', 'always-warn-profile')
+          await server.mocker.labelAccount('!warn', 'warn-account')
+          await server.mocker.labelProfile('!warn', 'warn-profile')
           await server.mocker.labelPost(
             '!warn',
-            await server.mocker.createPost(
-              'always-warn-posts',
-              'always-warn post',
-            ),
+            await server.mocker.createPost('warn-posts', 'warn post'),
           )
           await server.mocker.labelPost(
             '!warn',
             await server.mocker.createQuotePost(
-              'always-warn-posts',
-              'always-warn quote post',
+              'warn-posts',
+              'warn quote post',
               anchorPost,
             ),
           )
           await server.mocker.labelPost(
             '!warn',
             await server.mocker.createReply(
-              'always-warn-posts',
-              'always-warn reply',
+              'warn-posts',
+              'warn reply',
               anchorPost,
             ),
           )
@@ -291,6 +343,85 @@ async function main() {
             'account reply',
             anchorPost,
           )
+
+          await server.mocker.createPost('blocking-account', 'blocking post')
+          await server.mocker.createQuotePost(
+            'blocking-account',
+            'blocking quote post',
+            anchorPost,
+          )
+          await server.mocker.createReply(
+            'blocking-account',
+            'blocking reply',
+            anchorPost,
+          )
+          await server.mocker.users.alice.agent.app.bsky.graph.block.create(
+            {
+              repo: server.mocker.users.alice.did,
+            },
+            {
+              subject: server.mocker.users['blocking-account'].did,
+              createdAt: new Date().toISOString(),
+            },
+          )
+
+          await server.mocker.createPost('blockedby-account', 'blockedby post')
+          await server.mocker.createQuotePost(
+            'blockedby-account',
+            'blockedby quote post',
+            anchorPost,
+          )
+          await server.mocker.createReply(
+            'blockedby-account',
+            'blockedby reply',
+            anchorPost,
+          )
+          await server.mocker.users[
+            'blockedby-account'
+          ].agent.app.bsky.graph.block.create(
+            {
+              repo: server.mocker.users['blockedby-account'].did,
+            },
+            {
+              subject: server.mocker.users.alice.did,
+              createdAt: new Date().toISOString(),
+            },
+          )
+
+          await server.mocker.createPost(
+            'mutual-block-account',
+            'mutual-block post',
+          )
+          await server.mocker.createQuotePost(
+            'mutual-block-account',
+            'mutual-block quote post',
+            anchorPost,
+          )
+          await server.mocker.createReply(
+            'mutual-block-account',
+            'mutual-block reply',
+            anchorPost,
+          )
+          await server.mocker.users.alice.agent.app.bsky.graph.block.create(
+            {
+              repo: server.mocker.users.alice.did,
+            },
+            {
+              subject: server.mocker.users['mutual-block-account'].did,
+              createdAt: new Date().toISOString(),
+            },
+          )
+          await server.mocker.users[
+            'mutual-block-account'
+          ].agent.app.bsky.graph.block.create(
+            {
+              repo: server.mocker.users['mutual-block-account'].did,
+            },
+            {
+              subject: server.mocker.users.alice.did,
+              createdAt: new Date().toISOString(),
+            },
+          )
         }
       }
       console.log('Ready')
diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts
index 6c6d6db9c..7d2b5c363 100644
--- a/__e2e__/tests/profile-screen.test.ts
+++ b/__e2e__/tests/profile-screen.test.ts
@@ -53,7 +53,7 @@ describe('Profile screen', () => {
     await expect(element(by.id('profileHeaderDisplayName'))).toHaveText(
       'alice.test',
     )
-    await expect(element(by.id('profileHeaderDescription'))).toHaveText('')
+    await expect(element(by.id('profileHeaderDescription'))).not.toExist()
   })
 
   it('Set avi and banner via the edit profile modal', async () => {
@@ -107,13 +107,13 @@ describe('Profile screen', () => {
   })
 
   it('Can mute/unmute another user', async () => {
-    await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
+    await expect(element(by.id('profileHeaderAlert'))).not.toExist()
     await element(by.id('profileHeaderDropdownBtn')).tap()
     await element(by.text('Mute Account')).tap()
-    await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible()
+    await expect(element(by.id('profileHeaderAlert'))).toBeVisible()
     await element(by.id('profileHeaderDropdownBtn')).tap()
     await element(by.text('Unmute Account')).tap()
-    await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
+    await expect(element(by.id('profileHeaderAlert'))).not.toExist()
   })
 
   it('Can report another user', async () => {
diff --git a/app.json b/app.json
index e32cd9ca6..cf3af39df 100644
--- a/app.json
+++ b/app.json
@@ -4,7 +4,7 @@
     "slug": "bluesky",
     "scheme": "bluesky",
     "owner": "blueskysocial",
-    "version": "1.42.0",
+    "version": "1.43.0",
     "runtimeVersion": {
       "policy": "appVersion"
     },
@@ -43,7 +43,7 @@
       "backgroundColor": "#ffffff"
     },
     "android": {
-      "versionCode": 28,
+      "versionCode": 29,
       "adaptiveIcon": {
         "foregroundImage": "./assets/adaptive-icon.png",
         "backgroundColor": "#ffffff"
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 34c3e8bc4..21d90b93b 100644
--- a/jest/test-pds.ts
+++ b/jest/test-pds.ts
@@ -146,6 +146,7 @@ class Mocker {
     }
     return await agent.post({
       text,
+      langs: ['en'],
       createdAt: new Date().toISOString(),
     })
   }
@@ -162,6 +163,7 @@ class Mocker {
     return await agent.post({
       text,
       embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
+      langs: ['en'],
       createdAt: new Date().toISOString(),
     })
   }
@@ -178,6 +180,7 @@ class Mocker {
     return await agent.post({
       text,
       reply: {root: {uri, cid}, parent: {uri, cid}},
+      langs: ['en'],
       createdAt: new Date().toISOString(),
     })
   }
diff --git a/package.json b/package.json
index 05b08b3da..1da54bb27 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.42.0",
+  "version": "1.43.0",
   "private": true,
   "scripts": {
     "prepare": "is-ci || husky install",
@@ -24,7 +24,7 @@
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "^0.4.3",
+    "@atproto/api": "^0.5.2",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@expo/html-elements": "^0.4.2",
diff --git a/src/lib/api/hack-add-deleted-embed.ts b/src/lib/api/hack-add-deleted-embed.ts
new file mode 100644
index 000000000..59aad21a2
--- /dev/null
+++ b/src/lib/api/hack-add-deleted-embed.ts
@@ -0,0 +1,24 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ComAtprotoRepoStrongRef,
+} from '@atproto/api'
+
+/**
+ * HACK
+ * The server doesnt seem to be correctly giving the notFound view yet
+ * so I'm adding it manually for now
+ * -prf
+ */
+export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) {
+  const record = post.record as AppBskyFeedPost.Record
+  if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) {
+    post.embed = {
+      $type: 'app.bsky.embed.record#view',
+      record: {
+        $type: 'app.bsky.embed.record#viewNotFound',
+        uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri,
+      },
+    }
+  }
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
new file mode 100644
index 000000000..758e3de3a
--- /dev/null
+++ b/src/lib/moderation.ts
@@ -0,0 +1,77 @@
+import {ModerationCause, ProfileModeration} from '@atproto/api'
+
+export interface ModerationCauseDescription {
+  name: string
+  description: string
+}
+
+export function describeModerationCause(
+  cause: ModerationCause | undefined,
+  context: 'account' | 'content',
+): ModerationCauseDescription {
+  if (!cause) {
+    return {
+      name: 'Content Warning',
+      description:
+        'Moderator has chosen to set a general warning on the content.',
+    }
+  }
+  if (cause.type === 'blocking') {
+    return {
+      name: 'Blocked User',
+      description: 'You have blocked this user. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'blocked-by') {
+    return {
+      name: 'Blocking You',
+      description: 'This user has blocked you. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'muted') {
+    if (cause.source.type === 'user') {
+      return {
+        name: context === 'account' ? 'Muted User' : 'Post by muted user',
+        description: 'You have muted this user',
+      }
+    } else {
+      return {
+        name:
+          context === 'account'
+            ? `Muted by "${cause.source.list.name}"`
+            : `Post by muted user ("${cause.source.list.name}")`,
+        description: 'You have muted this user',
+      }
+    }
+  }
+  return cause.labelDef.strings[context].en
+}
+
+export function getProfileModerationCauses(
+  moderation: ProfileModeration,
+): ModerationCause[] {
+  /*
+  Gather everything on profile and account that blurs or alerts
+  */
+  return [
+    moderation.decisions.profile.cause,
+    ...moderation.decisions.profile.additionalCauses,
+    moderation.decisions.account.cause,
+    ...moderation.decisions.account.additionalCauses,
+  ].filter(cause => {
+    if (!cause) {
+      return false
+    }
+    if (cause?.type === 'label') {
+      if (
+        cause.labelDef.onwarn === 'blur' ||
+        cause.labelDef.onwarn === 'alert'
+      ) {
+        return true
+      } else {
+        return false
+      }
+    }
+    return true
+  }) as ModerationCause[]
+}
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index b98153732..29b7c3b50 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,10 +1,19 @@
+import {ModerationUI} from '@atproto/api'
+import {describeModerationCause} from '../moderation'
+
 // \u2705 = ✅
 // \u2713 = ✓
 // \u2714 = ✔
 // \u2611 = ☑
 const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
 
-export function sanitizeDisplayName(str: string): string {
+export function sanitizeDisplayName(
+  str: string,
+  moderation?: ModerationUI,
+): string {
+  if (moderation?.blur) {
+    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+  }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').trim()
   }
diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts
index 14aa607ed..141b4f937 100644
--- a/src/state/models/content/post-thread-item.ts
+++ b/src/state/models/content/post-thread-item.ts
@@ -3,9 +3,9 @@ import {
   AppBskyFeedPost as FeedPost,
   AppBskyFeedDefs,
   RichText,
+  PostModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {PostsFeedItemModel} from '../feeds/post'
 
 type PostView = AppBskyFeedDefs.PostView
@@ -67,10 +67,6 @@ export class PostThreadItemModel {
     return this.data.isThreadMuted
   }
 
-  get labelInfo(): PostLabelInfo {
-    return this.data.labelInfo
-  }
-
   get moderation(): PostModeration {
     return this.data.moderation
   }
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index c500174a5..85ed13cb4 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetPostThread as GetPostThread,
   AppBskyFeedDefs,
+  PostModeration,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
@@ -231,7 +232,6 @@ export class PostThreadModel {
       return
     }
     pruneReplies(res.data.thread)
-    sortThread(res.data.thread)
     const thread = new PostThreadItemModel(
       this.rootStore,
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
@@ -241,6 +241,7 @@ export class PostThreadModel {
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
       thread.uri,
     )
+    sortThread(thread)
     this.thread = thread
   }
 }
@@ -262,24 +263,28 @@ function pruneReplies(post: MaybePost) {
   }
 }
 
-function sortThread(post: MaybePost) {
-  if (post.notFound) {
+type MaybeThreadItem =
+  | PostThreadItemModel
+  | AppBskyFeedDefs.NotFoundPost
+  | AppBskyFeedDefs.BlockedPost
+function sortThread(item: MaybeThreadItem) {
+  if ('notFound' in item) {
     return
   }
-  post = post as AppBskyFeedDefs.ThreadViewPost
-  if (post.replies) {
-    post.replies.sort((a: MaybePost, b: MaybePost) => {
-      post = post as AppBskyFeedDefs.ThreadViewPost
-      if (a.notFound) {
+  item = item as PostThreadItemModel
+  if (item.replies) {
+    item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
+      if ('notFound' in a && a.notFound) {
         return 1
       }
-      if (b.notFound) {
+      if ('notFound' in b && b.notFound) {
         return -1
       }
-      a = a as AppBskyFeedDefs.ThreadViewPost
-      b = b as AppBskyFeedDefs.ThreadViewPost
-      const aIsByOp = a.post.author.did === post.post.author.did
-      const bIsByOp = b.post.author.did === post.post.author.did
+      item = item as PostThreadItemModel
+      a = a as PostThreadItemModel
+      b = b as PostThreadItemModel
+      const aIsByOp = a.post.author.did === item.post.author.did
+      const bIsByOp = b.post.author.did === item.post.author.did
       if (aIsByOp && bIsByOp) {
         return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
       } else if (aIsByOp) {
@@ -287,8 +292,31 @@ function sortThread(post: MaybePost) {
       } else if (bIsByOp) {
         return 1 // op's own reply
       }
+      // put moderated content down at the bottom
+      if (modScore(a.moderation) !== modScore(b.moderation)) {
+        return modScore(a.moderation) - modScore(b.moderation)
+      }
       return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
     })
-    post.replies.forEach(reply => sortThread(reply))
+    item.replies.forEach(reply => sortThread(reply))
+  }
+}
+
+function modScore(mod: PostModeration): number {
+  if (mod.content.blur && mod.content.noOverride) {
+    return 5
+  }
+  if (mod.content.blur) {
+    return 4
+  }
+  if (mod.content.alert) {
+    return 3
+  }
+  if (mod.embed.blur && mod.embed.noOverride) {
+    return 2
+  }
+  if (mod.embed.blur) {
+    return 1
   }
+  return 0
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 2ea4ada6e..26fa6008c 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -6,18 +6,14 @@ import {
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
   RichText,
+  moderateProfile,
+  ProfileModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {FollowState} from '../cache/my-follows'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types'
-import {
-  getProfileModeration,
-  filterAccountLabels,
-  filterProfileLabels,
-} from 'lib/labeling/helpers'
 import {track} from 'lib/analytics/analytics'
 
 export class ProfileViewerModel {
@@ -26,7 +22,8 @@ export class ProfileViewerModel {
   following?: string
   followedBy?: string
   blockedBy?: boolean
-  blocking?: string
+  blocking?: string;
+  [key: string]: unknown
 
   constructor() {
     makeAutoObservable(this)
@@ -53,7 +50,8 @@ export class ProfileModel {
   followsCount: number = 0
   postsCount: number = 0
   labels?: ComAtprotoLabelDefs.Label[] = undefined
-  viewer = new ProfileViewerModel()
+  viewer = new ProfileViewerModel();
+  [key: string]: unknown
 
   // added data
   descriptionRichText?: RichText = new RichText({text: ''})
@@ -85,18 +83,8 @@ export class ProfileModel {
     return this.hasLoaded && !this.hasContent
   }
 
-  get labelInfo(): ProfileLabelInfo {
-    return {
-      accountLabels: filterAccountLabels(this.labels),
-      profileLabels: filterProfileLabels(this.labels),
-      isMuted: this.viewer?.muted || false,
-      isBlocking: !!this.viewer?.blocking || false,
-      isBlockedBy: !!this.viewer?.blockedBy || false,
-    }
-  }
-
   get moderation(): ProfileModeration {
-    return getProfileModeration(this.rootStore, this.labelInfo)
+    return moderateProfile(this, this.rootStore.preferences.moderationOpts)
   }
 
   // public api
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
index 4b25ed4af..4bcb2bdd1 100644
--- a/src/state/models/discovery/foafs.ts
+++ b/src/state/models/discovery/foafs.ts
@@ -1,6 +1,7 @@
 import {
   AppBskyActorDefs,
   AppBskyGraphGetFollows as GetFollows,
+  moderateProfile,
 } from '@atproto/api'
 import {makeAutoObservable, runInAction} from 'mobx'
 import sampleSize from 'lodash.samplesize'
@@ -52,6 +53,13 @@ export class FoafsModel {
               cursor,
               limit: 100,
             })
+          res.data.follows = res.data.follows.filter(
+            profile =>
+              !moderateProfile(
+                profile,
+                this.rootStore.preferences.moderationOpts,
+              ).account.filter,
+          )
           this.rootStore.me.follows.hydrateProfiles(res.data.follows)
           if (!res.data.cursor) {
             break
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index 50faae614..533e14eab 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
@@ -69,7 +69,12 @@ export class SuggestedActorsModel {
         limit: 25,
         cursor: this.loadMoreCursor,
       })
-      const {actors, cursor} = res.data
+      let {actors, cursor} = res.data
+      actors = actors.filter(
+        actor =>
+          !moderateProfile(actor, this.rootStore.preferences.moderationOpts)
+            .account.filter,
+      )
       this.rootStore.me.follows.hydrateProfiles(actors)
 
       runInAction(() => {
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index b7ac3a53b..5f170062d 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -8,6 +8,8 @@ import {
   AppBskyFeedLike,
   AppBskyGraphFollow,
   ComAtprotoLabelDefs,
+  moderatePost,
+  moderateProfile,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import chunk from 'lodash.chunk'
@@ -15,16 +17,6 @@ import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from '../root-store'
 import {PostThreadModel} from '../content/post-thread'
 import {cleanError} from 'lib/strings/errors'
-import {
-  PostLabelInfo,
-  PostModeration,
-  ModerationBehaviorCode,
-} from 'lib/labeling/types'
-import {
-  getPostModeration,
-  filterAccountLabels,
-  filterProfileLabels,
-} from 'lib/labeling/helpers'
 
 const GROUPABLE_REASONS = ['like', 'repost', 'follow']
 const PAGE_SIZE = 30
@@ -100,27 +92,19 @@ export class NotificationsFeedItemModel {
     }
   }
 
-  get labelInfo(): PostLabelInfo {
-    const addedInfo = this.additionalPost?.thread?.labelInfo
-    return {
-      postLabels: (this.labels || []).concat(addedInfo?.postLabels || []),
-      accountLabels: filterAccountLabels(this.author.labels).concat(
-        addedInfo?.accountLabels || [],
-      ),
-      profileLabels: filterProfileLabels(this.author.labels).concat(
-        addedInfo?.profileLabels || [],
-      ),
-      isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
-      mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
-      isBlocking:
-        !!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
-      isBlockedBy:
-        !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false,
+  get shouldFilter(): boolean {
+    if (this.additionalPost?.thread) {
+      const postMod = moderatePost(
+        this.additionalPost.thread.data.post,
+        this.rootStore.preferences.moderationOpts,
+      )
+      return postMod.content.filter || false
     }
-  }
-
-  get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
+    const profileMod = moderateProfile(
+      this.author,
+      this.rootStore.preferences.moderationOpts,
+    )
+    return profileMod.account.filter || false
   }
 
   get numUnreadInGroup(): number {
@@ -565,8 +549,7 @@ export class NotificationsFeedModel {
   ): NotificationsFeedItemModel[] {
     return items
       .filter(item => {
-        const hideByLabel =
-          item.moderation.list.behavior === ModerationBehaviorCode.Hide
+        const hideByLabel = item.shouldFilter
         let mutedThread = !!(
           item.reasonSubjectRootUri &&
           this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index 47039c72a..68cc3de4c 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -3,21 +3,13 @@ import {
   AppBskyFeedPost as FeedPost,
   AppBskyFeedDefs,
   RichText,
+  moderatePost,
+  PostModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {updateDataOptimistically} from 'lib/async/revertible'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {
-  getEmbedLabels,
-  getEmbedMuted,
-  getEmbedMutedByList,
-  getEmbedBlocking,
-  getEmbedBlockedBy,
-  filterAccountLabels,
-  filterProfileLabels,
-  getPostModeration,
-} from 'lib/labeling/helpers'
 import {track} from 'lib/analytics/analytics'
+import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
 
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@@ -44,6 +36,7 @@ export class PostsFeedItemModel {
     if (FeedPost.isRecord(this.post.record)) {
       const valid = FeedPost.validateRecord(this.post.record)
       if (valid.success) {
+        hackAddDeletedEmbed(this.post)
         this.postRecord = this.post.record
         this.richText = new RichText(this.postRecord, {cleanNewlines: true})
       } else {
@@ -86,33 +79,8 @@ export class PostsFeedItemModel {
     return this.rootStore.mutedThreads.uris.has(this.rootUri)
   }
 
-  get labelInfo(): PostLabelInfo {
-    return {
-      postLabels: (this.post.labels || []).concat(
-        getEmbedLabels(this.post.embed),
-      ),
-      accountLabels: filterAccountLabels(this.post.author.labels),
-      profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted:
-        this.post.author.viewer?.muted ||
-        getEmbedMuted(this.post.embed) ||
-        false,
-      mutedByList:
-        this.post.author.viewer?.mutedByList ||
-        getEmbedMutedByList(this.post.embed),
-      isBlocking:
-        !!this.post.author.viewer?.blocking ||
-        getEmbedBlocking(this.post.embed) ||
-        false,
-      isBlockedBy:
-        !!this.post.author.viewer?.blockedBy ||
-        getEmbedBlockedBy(this.post.embed) ||
-        false,
-    }
-  }
-
   get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
+    return moderatePost(this.post, this.rootStore.preferences.moderationOpts)
   }
 
   copy(v: FeedViewPost) {
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
index 239bc5b6a..c02faed3b 100644
--- a/src/state/models/feeds/posts-slice.ts
+++ b/src/state/models/feeds/posts-slice.ts
@@ -1,7 +1,6 @@
 import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {mergePostModerations} from 'lib/labeling/helpers'
 import {PostsFeedItemModel} from './post'
 
 let _idCounter = 0
@@ -55,7 +54,20 @@ export class PostsFeedSliceModel {
   }
 
   get moderation() {
-    return mergePostModerations(this.items.map(item => item.moderation))
+    // prefer the most stringent item
+    const topItem = this.items.find(item => item.moderation.content.filter)
+    if (topItem) {
+      return topItem.moderation
+    }
+    // otherwise just use the first one
+    return this.items[0].moderation
+  }
+
+  shouldFilter(ignoreFilterForDid: string | undefined): boolean {
+    const mods = this.items
+      .filter(item => item.post.author.did !== ignoreFilterForDid)
+      .map(item => item.moderation)
+    return !!mods.find(mod => mod.content.filter)
   }
 
   containsUri(uri: string) {
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index e1c0b1f71..a892d8d34 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,9 +1,14 @@
 import {makeAutoObservable, runInAction} from 'mobx'
+import {LabelPreference as APILabelPreference} from '@atproto/api'
 import AwaitLock from 'await-lock'
 import isEqual from 'lodash.isequal'
 import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
-import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
+import {
+  ComAtprotoLabelDefs,
+  AppBskyActorDefs,
+  ModerationOpts,
+} from '@atproto/api'
 import {LabelValGroup} from 'lib/labeling/types'
 import {getLabelValueGroup} from 'lib/labeling/helpers'
 import {
@@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants'
 import {isIOS, deviceLocales} from 'platform/detection'
 import {LANGUAGES} from '../../../locale/languages'
 
-export type LabelPreference = 'show' | 'warn' | 'hide'
+// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
+export type LabelPreference = APILabelPreference | 'show'
 const LABEL_GROUPS = [
   'nsfw',
   'nudity',
@@ -408,6 +414,43 @@ export class PreferencesModel {
     return res
   }
 
+  get moderationOpts(): ModerationOpts {
+    return {
+      userDid: this.rootStore.session.currentSession?.did || '',
+      adultContentEnabled: this.adultContentEnabled,
+      labelerSettings: [
+        {
+          labeler: {
+            did: '',
+            displayName: 'Bluesky Social',
+          },
+          settings: {
+            // TEMP translate old settings until this UI can be migrated -prf
+            porn: tempfixLabelPref(this.contentLabels.nsfw),
+            sexual: tempfixLabelPref(this.contentLabels.suggestive),
+            nudity: tempfixLabelPref(this.contentLabels.nudity),
+            nsfl: tempfixLabelPref(this.contentLabels.gore),
+            corpse: tempfixLabelPref(this.contentLabels.gore),
+            gore: tempfixLabelPref(this.contentLabels.gore),
+            torture: tempfixLabelPref(this.contentLabels.gore),
+            'self-harm': tempfixLabelPref(this.contentLabels.gore),
+            'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
+            'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
+            'intolerant-sexual-orientation': tempfixLabelPref(
+              this.contentLabels.hate,
+            ),
+            'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
+            intolerant: tempfixLabelPref(this.contentLabels.hate),
+            'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
+            spam: tempfixLabelPref(this.contentLabels.spam),
+            impersonation: tempfixLabelPref(this.contentLabels.impersonation),
+            scam: 'warn',
+          },
+        },
+      ],
+    }
+  }
+
   async setSavedFeeds(saved: string[], pinned: string[]) {
     const oldSaved = this.savedFeeds
     const oldPinned = this.pinnedFeeds
@@ -485,3 +528,11 @@ export class PreferencesModel {
     this.requireAltTextEnabled = !this.requireAltTextEnabled
   }
 }
+
+// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
+function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
+  if (pref === 'show') {
+    return 'ignore'
+  }
+  return pref
+}
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index e33a34acf..476277592 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,4 +1,4 @@
-import {AppBskyEmbedRecord} from '@atproto/api'
+import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {ProfileModel} from '../content/profile'
@@ -42,6 +42,12 @@ export interface ServerInputModal {
   onSelect: (url: string) => void
 }
 
+export interface ModerationDetailsModal {
+  name: 'moderation-details'
+  context: 'account' | 'content'
+  moderation: ModerationUI
+}
+
 export interface ReportPostModal {
   name: 'report-post'
   postUri: string
@@ -146,6 +152,7 @@ export type Modal =
   | PreferencesHomeFeed
 
   // Moderation
+  | ModerationDetailsModal
   | ReportAccountModal
   | ReportPostModal
   | CreateOrEditMuteListModal
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 00d061616..469492971 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -30,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -136,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'onboarding') {
     snapPoints = OnboardingModal.snapPoints
     element = <OnboardingModal.Component />
+  } else if (activeModal?.name === 'moderation-details') {
+    snapPoints = ModerationDetailsModal.snapPoints
+    element = <ModerationDetailsModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 39cdbd868..df13dfed2 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
@@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <PreferencesHomeFeed.Component />
   } else if (modal.name === 'onboarding') {
     element = <OnboardingModal.Component />
+  } else if (modal.name === 'moderation-details') {
+    element = <ModerationDetailsModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
new file mode 100644
index 000000000..abeb2fdf4
--- /dev/null
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -0,0 +1,101 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {listUriToHref} from 'lib/strings/url-helpers'
+import {Button} from '../util/forms/Button'
+
+export const snapPoints = [300]
+
+export function Component({
+  context,
+  moderation,
+}: {
+  context: 'account' | 'content'
+  moderation: ModerationUI
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  let name
+  let description
+  if (!moderation.cause) {
+    name = 'Content Warning'
+    description =
+      'Moderator has chosen to set a general warning on the content.'
+  } else if (moderation.cause.type === 'blocking') {
+    name = 'Account Blocked'
+    description = 'You have blocked this user. You cannot view their content.'
+  } else if (moderation.cause.type === 'blocked-by') {
+    name = 'Account Blocks You'
+    description = 'This user has blocked you. You cannot view their content.'
+  } else if (moderation.cause.type === 'muted') {
+    if (moderation.cause.source.type === 'user') {
+      name = 'Account Muted'
+      description = 'You have muted this user.'
+    } else {
+      const list = moderation.cause.source.list
+      name = <>Account Muted by List</>
+      description = (
+        <>
+          This user is included the{' '}
+          <TextLink
+            type="2xl"
+            href={listUriToHref(list.uri)}
+            text={list.name}
+            style={pal.link}
+          />{' '}
+          list which you have muted.
+        </>
+      )
+    }
+  } else {
+    name = moderation.cause.labelDef.strings[context].en.name
+    description = moderation.cause.labelDef.strings[context].en.description
+  }
+
+  return (
+    <View testID="moderationDetailsModal" style={[styles.container, pal.view]}>
+      <Text type="title-xl" style={[pal.text, styles.title]}>
+        {name}
+      </Text>
+      <Text type="2xl" style={[pal.text, styles.description]}>
+        {description}
+      </Text>
+      <View style={s.flex1} />
+      <Button
+        type="primary"
+        style={styles.btn}
+        onPress={() => store.shell.closeModal()}>
+        <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
+          Okay
+        </Text>
+      </Button>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingHorizontal: isDesktopWeb ? 0 : 14,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+  },
+  btn: {
+    paddingVertical: 14,
+    marginTop: isDesktopWeb ? 40 : 0,
+    marginBottom: isDesktopWeb ? 0 : 40,
+  },
+})
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 7b9f0715b..ce9f5bc0d 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -7,7 +7,11 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {AppBskyEmbedImages} from '@atproto/api'
+import {
+  AppBskyEmbedImages,
+  ProfileModeration,
+  moderateProfile,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {
   FontAwesomeIcon,
@@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {
-  getProfileViewBasicLabelInfo,
-  getProfileModeration,
-} from 'lib/labeling/helpers'
-import {ProfileModeration} from 'lib/labeling/types'
 import {formatCount} from '../util/numeric/format'
 import {makeProfileLink} from 'lib/routes/links'
 
@@ -99,9 +98,9 @@ export const FeedItem = observer(function ({
         handle: item.author.handle,
         displayName: item.author.displayName,
         avatar: item.author.avatar,
-        moderation: getProfileModeration(
-          store,
-          getProfileViewBasicLabelInfo(item.author),
+        moderation: moderateProfile(
+          item.author,
+          store.preferences.moderationOpts,
         ),
       },
       ...(item.additional?.map(({author}) => {
@@ -111,10 +110,7 @@ export const FeedItem = observer(function ({
           handle: author.handle,
           displayName: author.displayName,
           avatar: author.avatar,
-          moderation: getProfileModeration(
-            store,
-            getProfileViewBasicLabelInfo(author),
-          ),
+          moderation: moderateProfile(author, store.preferences.moderationOpts),
         }
       }) || []),
     ]
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index edf8d7749..b5469c6fb 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -26,7 +26,7 @@ import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -159,10 +159,9 @@ export const PostThreadItem = observer(function PostThreadItem({
 
   if (item._isHighlightedPost) {
     return (
-      <PostHider
+      <Link
         testID={`postThreadItem-by-${item.post.author.handle}`}
-        style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
-        moderation={item.moderation.thread}>
+        style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}>
         <PostSandboxWarning />
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
@@ -227,7 +226,16 @@ export const PostThreadItem = observer(function PostThreadItem({
           </View>
         </View>
         <View style={[s.pl10, s.pr10, s.pb10]}>
-          <ContentHider moderation={item.moderation.view}>
+          <ContentHider
+            moderation={item.moderation.content}
+            ignoreMute
+            style={styles.contentHider}
+            childContainerStyle={styles.contentHiderChild}>
+            <PostAlerts
+              moderation={item.moderation.content}
+              includeMute
+              style={styles.alert}
+            />
             {item.richText?.text ? (
               <View
                 style={[
@@ -242,9 +250,11 @@ export const PostThreadItem = observer(function PostThreadItem({
                 />
               </View>
             ) : undefined}
-            <ImageHider moderation={item.moderation.view} style={s.mb10}>
-              <PostEmbeds embed={item.post.embed} style={s.mb10} />
-            </ImageHider>
+            {item.post.embed && (
+              <ContentHider moderation={item.moderation.embed} style={s.mb10}>
+                <PostEmbeds embed={item.post.embed} style={s.mb10} />
+              </ContentHider>
+            )}
           </ContentHider>
           <ExpandedPostDetails
             post={item.post}
@@ -311,7 +321,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             />
           </View>
         </View>
-      </PostHider>
+      </Link>
     )
   } else {
     return (
@@ -325,7 +335,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             pal.view,
             item._showParentReplyLine && styles.noTopBorder,
           ]}
-          moderation={item.moderation.thread}>
+          moderation={item.moderation.content}>
           {item._showParentReplyLine && (
             <View
               style={[
@@ -360,32 +370,34 @@ export const PostThreadItem = observer(function PostThreadItem({
                 timestamp={item.post.indexedAt}
                 postHref={itemHref}
               />
-              <ContentHider
-                moderation={item.moderation.thread}
-                containerStyle={styles.contentHider}>
-                {item.richText?.text ? (
-                  <View style={styles.postTextContainer}>
-                    <RichText
-                      type="post-text"
-                      richText={item.richText}
-                      style={[pal.text, s.flex1]}
-                      lineHeight={1.3}
-                    />
-                  </View>
-                ) : undefined}
-                <ImageHider style={s.mb10} moderation={item.moderation.thread}>
+              <PostAlerts
+                moderation={item.moderation.content}
+                style={styles.alert}
+              />
+              {item.richText?.text ? (
+                <View style={styles.postTextContainer}>
+                  <RichText
+                    type="post-text"
+                    richText={item.richText}
+                    style={[pal.text, s.flex1]}
+                    lineHeight={1.3}
+                  />
+                </View>
+              ) : undefined}
+              {item.post.embed && (
+                <ContentHider style={s.mb10} moderation={item.moderation.embed}>
                   <PostEmbeds embed={item.post.embed} style={s.mb10} />
-                </ImageHider>
-                {needsTranslation && (
-                  <View style={[pal.borderDark, styles.translateLink]}>
-                    <Link href={translatorUrl} title="Translate">
-                      <Text type="sm" style={pal.link}>
-                        Translate this post
-                      </Text>
-                    </Link>
-                  </View>
-                )}
-              </ContentHider>
+                </ContentHider>
+              )}
+              {needsTranslation && (
+                <View style={[pal.borderDark, styles.translateLink]}>
+                  <Link href={translatorUrl} title="Translate">
+                    <Text type="sm" style={pal.link}>
+                      Translate this post
+                    </Text>
+                  </Link>
+                </View>
+              )}
               <PostCtrls
                 itemUri={itemUri}
                 itemCid={itemCid}
@@ -515,6 +527,9 @@ const styles = StyleSheet.create({
     paddingRight: 5,
     maxWidth: 240,
   },
+  alert: {
+    marginBottom: 6,
+  },
   postTextContainer: {
     flexDirection: 'row',
     alignItems: 'center',
@@ -531,7 +546,10 @@ const styles = StyleSheet.create({
     marginBottom: 6,
   },
   contentHider: {
-    marginTop: 4,
+    marginBottom: 6,
+  },
+  contentHiderChild: {
+    marginTop: 6,
   },
   expandedInfo: {
     flexDirection: 'row',
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index ac5e7d20b..4b03d4667 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -19,9 +19,8 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
@@ -206,10 +205,7 @@ const PostLoaded = observer(
     }, [item, setDeleted, store])
 
     return (
-      <PostHider
-        href={itemHref}
-        style={[styles.outer, pal.view, pal.border, style]}
-        moderation={item.moderation.list}>
+      <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
         {showReplyLine && <View style={styles.replyLine} />}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
@@ -251,8 +247,13 @@ const PostLoaded = observer(
               </View>
             )}
             <ContentHider
-              moderation={item.moderation.list}
-              containerStyle={styles.contentHider}>
+              moderation={item.moderation.content}
+              style={styles.contentHider}
+              childContainerStyle={styles.contentHiderChild}>
+              <PostAlerts
+                moderation={item.moderation.content}
+                style={styles.alert}
+              />
               {item.richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
@@ -264,9 +265,9 @@ const PostLoaded = observer(
                   />
                 </View>
               ) : undefined}
-              <ImageHider moderation={item.moderation.list} style={s.mb10}>
+              <ContentHider moderation={item.moderation.embed} style={s.mb10}>
                 <PostEmbeds embed={item.post.embed} style={s.mb10} />
-              </ImageHider>
+              </ContentHider>
               {needsTranslation && (
                 <View style={[pal.borderDark, styles.translateLink]}>
                   <Link href={translatorUrl} title="Translate">
@@ -302,7 +303,7 @@ const PostLoaded = observer(
             />
           </View>
         </View>
-      </PostHider>
+      </Link>
     )
   },
 )
@@ -323,6 +324,9 @@ const styles = StyleSheet.create({
   layoutContent: {
     flex: 1,
   },
+  alert: {
+    marginBottom: 6,
+  },
   postTextContainer: {
     flexDirection: 'row',
     alignItems: 'center',
@@ -341,6 +345,9 @@ const styles = StyleSheet.create({
     borderLeftColor: colors.gray2,
   },
   contentHider: {
-    marginTop: 4,
+    marginBottom: 6,
+  },
+  contentHiderChild: {
+    marginTop: 6,
   },
 })
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 75c321145..9d2bc72bc 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,16 +8,14 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {PostsFeedItemModel} from 'state/models/feeds/post'
-import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {RichText} from '../util/text/RichText'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import * as Toast from '../util/Toast'
@@ -35,13 +33,11 @@ export const FeedItem = observer(function ({
   item,
   isThreadChild,
   isThreadParent,
-  ignoreMuteFor,
 }: {
   item: PostsFeedItemModel
   isThreadChild?: boolean
   isThreadParent?: boolean
   showReplyLine?: boolean
-  ignoreMuteFor?: string
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -147,26 +143,17 @@ export const FeedItem = observer(function ({
     isThreadParent ? styles.outerNoBottom : undefined,
   ]
 
-  // moderation override
-  let moderation = item.moderation.list
-  if (
-    ignoreMuteFor === item.post.author.did &&
-    moderation.isMute &&
-    !moderation.noOverride
-  ) {
-    moderation = {behavior: ModerationBehaviorCode.Show}
-  }
-
   if (!record || deleted) {
     return <View />
   }
 
   return (
-    <PostHider
+    <Link
       testID={`feedItem-by-${item.post.author.handle}`}
       style={outerStyles}
       href={itemHref}
-      moderation={moderation}>
+      noFeedback
+      accessible={false}>
       {isThreadChild && (
         <View
           style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@@ -255,8 +242,14 @@ export const FeedItem = observer(function ({
             </View>
           )}
           <ContentHider
-            moderation={moderation}
-            containerStyle={styles.contentHider}>
+            moderation={item.moderation.content}
+            ignoreMute
+            style={styles.contentHider}
+            childContainerStyle={styles.contentHiderChild}>
+            <PostAlerts
+              moderation={item.moderation.content}
+              style={styles.alert}
+            />
             {item.richText?.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
@@ -267,9 +260,11 @@ export const FeedItem = observer(function ({
                 />
               </View>
             ) : undefined}
-            <ImageHider moderation={item.moderation.list} style={styles.embed}>
+            <ContentHider
+              moderation={item.moderation.embed}
+              style={styles.embed}>
               <PostEmbeds embed={item.post.embed} style={styles.embed} />
-            </ImageHider>
+            </ContentHider>
             {needsTranslation && (
               <View style={[pal.borderDark, styles.translateLink]}>
                 <Link href={translatorUrl} title="Translate">
@@ -306,7 +301,7 @@ export const FeedItem = observer(function ({
           />
         </View>
       </View>
-    </PostHider>
+    </Link>
   )
 })
 
@@ -358,6 +353,10 @@ const styles = StyleSheet.create({
   layoutContent: {
     flex: 1,
   },
+  alert: {
+    marginTop: 6,
+    marginBottom: 6,
+  },
   postTextContainer: {
     flexDirection: 'row',
     alignItems: 'center',
@@ -365,7 +364,10 @@ const styles = StyleSheet.create({
     paddingBottom: 4,
   },
   contentHider: {
-    marginTop: 4,
+    marginBottom: 6,
+  },
+  contentHiderChild: {
+    marginTop: 6,
   },
   embed: {
     marginBottom: 6,
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index b73d4a99d..6345f777f 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
 import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
 import {AtUri} from '@atproto/api'
 import {Link} from '../util/Link'
@@ -7,65 +8,61 @@ 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'
 import {makeProfileLink} from 'lib/routes/links'
 
-export function FeedSlice({
-  slice,
-  ignoreMuteFor,
-}: {
-  slice: PostsFeedSliceModel
-  ignoreMuteFor?: string
-}) {
-  if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
-    if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
+export const FeedSlice = observer(
+  ({
+    slice,
+    ignoreFilterFor,
+  }: {
+    slice: PostsFeedSliceModel
+    ignoreFilterFor?: string
+  }) => {
+    if (slice.shouldFilter(ignoreFilterFor)) {
       return null
     }
-  }
-  if (slice.isThread && slice.items.length > 3) {
-    const last = slice.items.length - 1
+
+    if (slice.isThread && slice.items.length > 3) {
+      const last = slice.items.length - 1
+      return (
+        <>
+          <FeedItem
+            key={slice.items[0]._reactKey}
+            item={slice.items[0]}
+            isThreadParent={slice.isThreadParentAt(0)}
+            isThreadChild={slice.isThreadChildAt(0)}
+          />
+          <FeedItem
+            key={slice.items[1]._reactKey}
+            item={slice.items[1]}
+            isThreadParent={slice.isThreadParentAt(1)}
+            isThreadChild={slice.isThreadChildAt(1)}
+          />
+          <ViewFullThread slice={slice} />
+          <FeedItem
+            key={slice.items[last]._reactKey}
+            item={slice.items[last]}
+            isThreadParent={slice.isThreadParentAt(last)}
+            isThreadChild={slice.isThreadChildAt(last)}
+          />
+        </>
+      )
+    }
+
     return (
       <>
-        <FeedItem
-          key={slice.items[0]._reactKey}
-          item={slice.items[0]}
-          isThreadParent={slice.isThreadParentAt(0)}
-          isThreadChild={slice.isThreadChildAt(0)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-        <FeedItem
-          key={slice.items[1]._reactKey}
-          item={slice.items[1]}
-          isThreadParent={slice.isThreadParentAt(1)}
-          isThreadChild={slice.isThreadChildAt(1)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-        <ViewFullThread slice={slice} />
-        <FeedItem
-          key={slice.items[last]._reactKey}
-          item={slice.items[last]}
-          isThreadParent={slice.isThreadParentAt(last)}
-          isThreadChild={slice.isThreadChildAt(last)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
+        {slice.items.map((item, i) => (
+          <FeedItem
+            key={item._reactKey}
+            item={item}
+            isThreadParent={slice.isThreadParentAt(i)}
+            isThreadChild={slice.isThreadChildAt(i)}
+          />
+        ))}
       </>
     )
-  }
-
-  return (
-    <>
-      {slice.items.map((item, i) => (
-        <FeedItem
-          key={item._reactKey}
-          item={item}
-          isThreadParent={slice.isThreadParentAt(i)}
-          isThreadChild={slice.isThreadChildAt(i)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-      ))}
-    </>
-  )
-}
+  },
+)
 
 function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
   const pal = usePalette('default')
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 946e0f2ab..ba0c59def 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,7 +1,11 @@
 import * as React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ProfileModeration,
+} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -11,12 +15,11 @@ import {useStores} from 'state/index'
 import {FollowButton} from './FollowButton'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {
-  getProfileViewBasicLabelInfo,
-  getProfileModeration,
-} from 'lib/labeling/helpers'
-import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {makeProfileLink} from 'lib/routes/links'
+import {
+  describeModerationCause,
+  getProfileModerationCauses,
+} from 'lib/moderation'
 
 export const ProfileCard = observer(
   ({
@@ -25,7 +28,6 @@ export const ProfileCard = observer(
     noBg,
     noBorder,
     followers,
-    overrideModeration,
     renderButton,
   }: {
     testID?: string
@@ -33,7 +35,6 @@ export const ProfileCard = observer(
     noBg?: boolean
     noBorder?: boolean
     followers?: AppBskyActorDefs.ProfileView[] | undefined
-    overrideModeration?: boolean
     renderButton?: (
       profile: AppBskyActorDefs.ProfileViewBasic,
     ) => React.ReactNode
@@ -41,18 +42,11 @@ export const ProfileCard = observer(
     const store = useStores()
     const pal = usePalette('default')
 
-    const moderation = getProfileModeration(
-      store,
-      getProfileViewBasicLabelInfo(profile),
+    const moderation = moderateProfile(
+      profile,
+      store.preferences.moderationOpts,
     )
 
-    if (
-      moderation.list.behavior === ModerationBehaviorCode.Hide &&
-      !overrideModeration
-    ) {
-      return null
-    }
-
     return (
       <Link
         testID={testID}
@@ -82,20 +76,17 @@ export const ProfileCard = observer(
               lineHeight={1.2}>
               {sanitizeDisplayName(
                 profile.displayName || sanitizeHandle(profile.handle),
+                moderation.profile,
               )}
             </Text>
             <Text type="md" style={[pal.textLight]} numberOfLines={1}>
               {sanitizeHandle(profile.handle, '@')}
             </Text>
-            {!!profile.viewer?.followedBy && (
-              <View style={s.flexRow}>
-                <View style={[s.mt5, pal.btn, styles.pill]}>
-                  <Text type="xs" style={pal.text}>
-                    Follows You
-                  </Text>
-                </View>
-              </View>
-            )}
+            <ProfileCardPills
+              followedBy={!!profile.viewer?.followedBy}
+              moderation={moderation}
+            />
+            {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
           </View>
           {renderButton ? (
             <View style={styles.layoutButton}>{renderButton(profile)}</View>
@@ -114,6 +105,44 @@ export const ProfileCard = observer(
   },
 )
 
+function ProfileCardPills({
+  followedBy,
+  moderation,
+}: {
+  followedBy: boolean
+  moderation: ProfileModeration
+}) {
+  const pal = usePalette('default')
+
+  const causes = getProfileModerationCauses(moderation)
+  if (!followedBy && !causes.length) {
+    return null
+  }
+
+  return (
+    <View style={styles.pills}>
+      {followedBy && (
+        <View style={[s.mt5, pal.btn, styles.pill]}>
+          <Text type="xs" style={pal.text}>
+            Follows You
+          </Text>
+        </View>
+      )}
+      {causes.map(cause => {
+        const desc = describeModerationCause(cause, 'account')
+        return (
+          <View style={[s.mt5, pal.btn, styles.pill]}>
+            <Text type="xs" style={pal.text}>
+              {cause?.type === 'label' ? '⚠' : ''}
+              {desc.name}
+            </Text>
+          </View>
+        )
+      })}
+    </View>
+  )
+}
+
 const FollowersList = observer(
   ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
     const store = useStores()
@@ -125,9 +154,9 @@ const FollowersList = observer(
     const followersWithMods = followers
       .map(f => ({
         f,
-        mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
+        mod: moderateProfile(f, store.preferences.moderationOpts),
       }))
-      .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
+      .filter(({mod}) => !mod.account.filter)
 
     return (
       <View style={styles.followedBy}>
@@ -218,6 +247,12 @@ const styles = StyleSheet.create({
     paddingRight: 10,
     paddingBottom: 10,
   },
+  pills: {
+    flexDirection: 'row',
+    flexWrap: 'wrap',
+    columnGap: 6,
+    rowGap: 2,
+  },
   pill: {
     borderRadius: 4,
     paddingHorizontal: 6,
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index a372f0d81..f8531d76c 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -21,15 +21,13 @@ import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
 import {ThemedText} from '../util/text/ThemedText'
-import {TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
-import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
+import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
-import {listUriToHref} from 'lib/strings/url-helpers'
 import {isDesktopWeb, isNative} from 'platform/detection'
 import {FollowState} from 'state/models/cache/my-follows'
 import {shareUrl} from 'lib/sharing'
@@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer(
     }, [navigation])
 
     const onPressAvi = React.useCallback(() => {
-      if (view.avatar) {
+      if (
+        view.avatar &&
+        !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+      ) {
         store.shell.openLightbox(new ProfileImageLightbox(view))
       }
     }, [store, view])
@@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer(
               style={[pal.text, styles.title]}>
               {sanitizeDisplayName(
                 view.displayName || sanitizeHandle(view.handle),
+                view.moderation.profile,
               )}
             </Text>
           </View>
@@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer(
                   </Text>
                 </Text>
               </View>
-              {view.descriptionRichText ? (
+              {view.description &&
+              view.descriptionRichText &&
+              !view.moderation.profile.blur ? (
                 <RichText
                   testID="profileHeaderDescription"
                   style={[styles.description, pal.text]}
@@ -504,52 +508,7 @@ const ProfileHeaderLoaded = observer(
               ) : undefined}
             </>
           )}
-          <ProfileHeaderWarnings moderation={view.moderation.view} />
-          <View style={styles.moderationLines}>
-            {view.viewer.blocking ? (
-              <View
-                testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text]} />
-                <Text type="lg-medium" style={pal.text}>
-                  Account blocked
-                </Text>
-              </View>
-            ) : view.viewer.muted ? (
-              <View
-                testID="profileHeaderMutedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon
-                  icon={['far', 'eye-slash']}
-                  style={[pal.text]}
-                />
-                <Text type="lg-medium" style={pal.text}>
-                  Account muted{' '}
-                  {view.viewer.mutedByList && (
-                    <Text type="lg-medium" style={pal.text}>
-                      by{' '}
-                      <TextLink
-                        type="lg-medium"
-                        style={pal.link}
-                        href={listUriToHref(view.viewer.mutedByList.uri)}
-                        text={view.viewer.mutedByList.name}
-                      />
-                    </Text>
-                  )}
-                </Text>
-              </View>
-            ) : undefined}
-            {view.viewer.blockedBy && (
-              <View
-                testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text]} />
-                <Text type="lg-medium" style={pal.text}>
-                  This account has blocked you
-                </Text>
-              </View>
-            )}
-          </View>
+          <ProfileHeaderAlerts moderation={view.moderation} />
         </View>
         {!isDesktopWeb && !hideBackButton && (
           <TouchableWithoutFeedback
@@ -693,19 +652,6 @@ const styles = StyleSheet.create({
     paddingVertical: 2,
   },
 
-  moderationLines: {
-    gap: 6,
-  },
-
-  moderationNotice: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 8,
-    paddingHorizontal: 16,
-    paddingVertical: 14,
-    gap: 8,
-  },
-
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
 })
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d999ffb31..0f34f75aa 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
 import Svg, {Circle, Rect, Path} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {HighPriorityImage} from 'view/com/util/images/Image'
+import {ModerationUI} from '@atproto/api'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -13,7 +14,6 @@ import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {AvatarModeration} from 'lib/labeling/types'
 import {UserPreviewLink} from './UserPreviewLink'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
 
@@ -23,7 +23,7 @@ interface BaseUserAvatarProps {
   type?: Type
   size: number
   avatar?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
 }
 
 interface UserAvatarProps extends BaseUserAvatarProps {
@@ -213,20 +213,20 @@ export function UserAvatar({
     ],
   )
 
-  const warning = useMemo(() => {
-    if (!moderation?.warn) {
+  const alert = useMemo(() => {
+    if (!moderation?.alert) {
       return null
     }
     return (
-      <View style={[styles.warningIconContainer, pal.view]}>
+      <View style={[styles.alertIconContainer, pal.view]}>
         <FontAwesomeIcon
           icon="exclamation-circle"
-          style={styles.warningIcon}
+          style={styles.alertIcon}
           size={Math.floor(size / 3)}
         />
       </View>
     )
-  }, [moderation?.warn, size, pal])
+  }, [moderation?.alert, size, pal])
 
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
@@ -259,12 +259,12 @@ export function UserAvatar({
         source={{uri: avatar}}
         blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
       />
-      {warning}
+      {alert}
     </View>
   ) : (
     <View style={{width: size, height: size}}>
       <DefaultAvatar type={type} size={size} />
-      {warning}
+      {alert}
     </View>
   )
 }
@@ -289,13 +289,13 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     backgroundColor: colors.gray5,
   },
-  warningIconContainer: {
+  alertIconContainer: {
     position: 'absolute',
     right: 0,
     bottom: 0,
     borderRadius: 100,
   },
-  warningIcon: {
+  alertIcon: {
     color: colors.red3,
   },
 })
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index b7e91b5dd..7c5c583c2 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,6 +1,7 @@
 import React, {useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
@@ -10,7 +11,6 @@ import {
   useCameraPermission,
 } from 'lib/hooks/usePermissions'
 import {usePalette} from 'lib/hooks/usePalette'
-import {AvatarModeration} from 'lib/labeling/types'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
@@ -21,7 +21,7 @@ export function UserBanner({
   onSelectNewBanner,
 }: {
   banner?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
 }) {
   const store = useStores()
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index ac5c8395d..6be2f8be0 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -1,36 +1,32 @@
 import React from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
+import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
-import {addStyle} from 'lib/styles'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
+import {InfoCircleIcon} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 export function ContentHider({
   testID,
   moderation,
+  ignoreMute,
   style,
-  containerStyle,
+  childContainerStyle,
   children,
 }: React.PropsWithChildren<{
   testID?: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
+  ignoreMute?: boolean
   style?: StyleProp<ViewStyle>
-  containerStyle?: StyleProp<ViewStyle>
+  childContainerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const onPressShow = React.useCallback(() => {
-    setOverride(true)
-  }, [setOverride])
-  const onPressHide = React.useCallback(() => {
-    setOverride(false)
-  }, [setOverride])
 
-  if (
-    moderation.behavior === ModerationBehaviorCode.Show ||
-    moderation.behavior === ModerationBehaviorCode.Warn ||
-    moderation.behavior === ModerationBehaviorCode.WarnImages
-  ) {
+  if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -38,73 +34,61 @@ export function ContentHider({
     )
   }
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <View style={[styles.container, pal.view, pal.border, containerStyle]}>
+    <View testID={testID} style={style}>
       <Pressable
-        onPress={override ? onPressHide : onPressShow}
-        accessibilityLabel={override ? 'Hide post' : 'Show post'}
-        // TODO: The text labelling should be split up so controls have unique roles
-        accessibilityHint={
-          override
-            ? 'Re-hide post'
-            : 'Shows post hidden based on your moderation settings'
-        }
-        style={[
-          styles.description,
-          pal.viewLight,
-          override && styles.descriptionOpen,
-        ]}>
-        <Text type="md" style={pal.textLight}>
-          {moderation.reason || 'Content warning'}
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
+        style={[styles.cover, pal.viewLight]}>
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <InfoCircleIcon size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
         </Text>
-        <View style={styles.showBtn}>
-          <Text type="md-medium" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </View>
-      </Pressable>
-      {override && (
-        <View style={[styles.childrenContainer, pal.border]}>
-          <View testID={testID} style={addStyle(style, styles.child)}>
-            {children}
+        {!moderation.noOverride && (
+          <View style={styles.showBtn}>
+            <Text type="xl" style={pal.link}>
+              {override ? 'Hide' : 'Show'}
+            </Text>
           </View>
-        </View>
-      )}
+        )}
+      </Pressable>
+      {override && <View style={childContainerStyle}>{children}</View>}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    marginBottom: 10,
-    borderWidth: 1,
-    borderRadius: 12,
-  },
-  description: {
+  cover: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 4,
     paddingVertical: 14,
     paddingLeft: 14,
-    paddingRight: 18,
-    borderRadius: 12,
-  },
-  descriptionOpen: {
-    borderBottomLeftRadius: 0,
-    borderBottomRightRadius: 0,
-  },
-  icon: {
-    marginRight: 10,
+    paddingRight: isDesktopWeb ? 18 : 22,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
-  childrenContainer: {
-    paddingHorizontal: 12,
-    paddingTop: 8,
-  },
-  child: {},
 })
diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx
deleted file mode 100644
index 40c9d0a21..000000000
--- a/src/view/com/util/moderation/ImageHider.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from '../text/Text'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-import {isDesktopWeb} from 'platform/detection'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
-
-export function ImageHider({
-  testID,
-  moderation,
-  style,
-  children,
-}: React.PropsWithChildren<{
-  testID?: string
-  moderation: ModerationBehavior
-  style?: StyleProp<ViewStyle>
-}>) {
-  const pal = usePalette('default')
-  const [override, setOverride] = React.useState(false)
-  const onPressToggle = React.useCallback(() => {
-    setOverride(v => !v)
-  }, [setOverride])
-
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior !== ModerationBehaviorCode.WarnImages) {
-    return (
-      <View testID={testID} style={style}>
-        {children}
-      </View>
-    )
-  }
-
-  return (
-    <View testID={testID} style={style}>
-      <View style={[styles.cover, pal.viewLight]}>
-        <Pressable
-          onPress={onPressToggle}
-          style={[styles.toggleBtn]}
-          accessibilityLabel="Show image"
-          accessibilityHint="">
-          <FontAwesomeIcon
-            icon={override ? 'eye' : ['far', 'eye-slash']}
-            size={24}
-            style={pal.text as FontAwesomeIconStyle}
-          />
-          <Text type="lg" style={pal.text}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <View style={styles.flex1} />
-          <Text type="xl-bold" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </Pressable>
-      </View>
-      {override && children}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  cover: {
-    borderRadius: 8,
-    marginTop: 4,
-  },
-  toggleBtn: {
-    flexDirection: 'row',
-    gap: 8,
-    alignItems: 'center',
-    paddingHorizontal: isDesktopWeb ? 24 : 20,
-    paddingVertical: isDesktopWeb ? 20 : 18,
-  },
-  flex1: {
-    flex: 1,
-  },
-})
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
new file mode 100644
index 000000000..45937c2d8
--- /dev/null
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {InfoCircleIcon} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function PostAlerts({
+  moderation,
+  includeMute,
+  style,
+}: {
+  moderation: ModerationUI
+  includeMute?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const shouldAlert =
+    !!moderation.cause &&
+    (moderation.alert ||
+      (includeMute && moderation.blur && moderation.cause?.type === 'muted'))
+  if (!shouldAlert) {
+    return null
+  }
+
+  const desc = describeModerationCause(moderation.cause, 'content')
+  return (
+    <Pressable
+      onPress={() => {
+        store.shell.openModal({
+          name: 'moderation-details',
+          context: 'content',
+          moderation,
+        })
+      }}
+      accessibilityRole="button"
+      accessibilityLabel="Learn more about this warning"
+      accessibilityHint=""
+      style={[styles.container, pal.viewLight, style]}>
+      <InfoCircleIcon style={pal.text} size={18} />
+      <Text type="lg" style={pal.text}>
+        {desc.name}
+      </Text>
+      <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+        Learn More
+      </Text>
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    paddingVertical: 8,
+    paddingLeft: 14,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index f2b6dbddd..dc74d3e39 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -1,17 +1,20 @@
 import React, {ComponentProps} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {StyleSheet, Pressable, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {InfoCircleIcon} from 'lib/icons'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 interface Props extends ComponentProps<typeof Link> {
   // testID?: string
   // href?: string
   // style: StyleProp<ViewStyle>
-  moderation: ModerationBehavior
+  moderation: ModerationUI
 }
 
 export function PostHider({
@@ -22,60 +25,71 @@ export function PostHider({
   children,
   ...props
 }: Props) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const bg = override ? pal.viewLight : pal.view
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior === ModerationBehaviorCode.Warn) {
+  if (!moderation.blur) {
     return (
-      <>
-        <View style={[styles.description, bg, pal.border]}>
-          <FontAwesomeIcon
-            icon={['far', 'eye-slash']}
-            style={[styles.icon, pal.text]}
-          />
-          <Text type="md" style={pal.textLight}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <TouchableOpacity
-            style={styles.showBtn}
-            onPress={() => setOverride(v => !v)}
-            accessibilityRole="button">
-            <Text type="md" style={pal.link}>
-              {override ? 'Hide' : 'Show'} post
-            </Text>
-          </TouchableOpacity>
-        </View>
-        {override && (
-          <View style={[styles.childrenContainer, pal.border, bg]}>
-            <Link
-              testID={testID}
-              style={addStyle(style, styles.child)}
-              href={href}
-              noFeedback>
-              {children}
-            </Link>
-          </View>
-        )}
-      </>
+      <Link
+        testID={testID}
+        style={style}
+        href={href}
+        noFeedback
+        accessible={false}
+        {...props}>
+        {children}
+      </Link>
     )
   }
 
-  // NOTE: any further label enforcement should occur in ContentContainer
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <Link
-      testID={testID}
-      style={style}
-      href={href}
-      noFeedback
-      accessible={false}
-      {...props}>
-      {children}
-    </Link>
+    <>
+      <Pressable
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
+        style={[styles.description, pal.viewLight]}>
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <InfoCircleIcon size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
+        </Text>
+        {!moderation.noOverride && (
+          <Text type="xl" style={[styles.showBtn, pal.link]}>
+            {override ? 'Hide' : 'Show'}
+          </Text>
+        )}
+      </Pressable>
+      {override && (
+        <View style={[styles.childrenContainer, pal.border, pal.viewLight]}>
+          <Link
+            testID={testID}
+            style={addStyle(style, styles.child)}
+            href={href}
+            noFeedback>
+            {children}
+          </Link>
+        </View>
+      )}
+    </>
   )
 }
 
@@ -83,22 +97,23 @@ const styles = StyleSheet.create({
   description: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
     paddingVertical: 14,
-    paddingHorizontal: 18,
-    borderTopWidth: 1,
-  },
-  icon: {
-    marginRight: 10,
+    paddingLeft: 18,
+    paddingRight: isDesktopWeb ? 18 : 22,
+    marginTop: 1,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
   childrenContainer: {
-    paddingHorizontal: 6,
+    paddingHorizontal: 4,
     paddingBottom: 6,
   },
   child: {
-    borderWidth: 1,
-    borderRadius: 12,
+    borderWidth: 0,
+    borderTopWidth: 0,
+    borderRadius: 8,
   },
 })
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 000000000..3cc3b5b9e
--- /dev/null
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ProfileModeration} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {InfoCircleIcon} from 'lib/icons'
+import {
+  describeModerationCause,
+  getProfileModerationCauses,
+} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function ProfileHeaderAlerts({
+  moderation,
+  style,
+}: {
+  moderation: ProfileModeration
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const causes = getProfileModerationCauses(moderation)
+  if (!causes.length) {
+    return null
+  }
+
+  return (
+    <View style={styles.grid}>
+      {causes.map(cause => {
+        const desc = describeModerationCause(cause, 'account')
+        return (
+          <Pressable
+            testID="profileHeaderAlert"
+            key={desc.name}
+            onPress={() => {
+              store.shell.openModal({
+                name: 'moderation-details',
+                context: 'content',
+                moderation: {cause},
+              })
+            }}
+            accessibilityRole="button"
+            accessibilityLabel="Learn more about this warning"
+            accessibilityHint=""
+            style={[styles.container, pal.viewLight, style]}>
+            <InfoCircleIcon style={pal.text} size={24} />
+            <Text type="lg" style={pal.text}>
+              {desc.name}
+            </Text>
+            <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+              Learn More
+            </Text>
+          </Pressable>
+        )
+      })}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  grid: {
+    gap: 4,
+  },
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    paddingVertical: 12,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
deleted file mode 100644
index 7a1a8e295..000000000
--- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-
-export function ProfileHeaderWarnings({
-  moderation,
-}: {
-  moderation: ModerationBehavior
-}) {
-  const palErr = usePalette('error')
-  if (moderation.behavior === ModerationBehaviorCode.Show) {
-    return null
-  }
-  return (
-    <View style={[styles.container, palErr.border, palErr.view]}>
-      <FontAwesomeIcon
-        icon="circle-exclamation"
-        style={palErr.text as FontAwesomeIconStyle}
-        size={20}
-      />
-      <Text style={palErr.text}>
-        This account has been flagged: {moderation.reason}
-      </Text>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 10,
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 10,
-    paddingVertical: 8,
-  },
-})
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
index 2e7b07e1a..b76b1101c 100644
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -1,16 +1,24 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  TouchableWithoutFeedback,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {NavigationProp} from 'lib/routes/types'
 import {Text} from '../text/Text'
 import {Button} from '../forms/Button'
 import {isDesktopWeb} from 'platform/detection'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
 
 export function ScreenHider({
   testID,
@@ -22,24 +30,17 @@ export function ScreenHider({
 }: React.PropsWithChildren<{
   testID?: string
   screenDescription: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const [override, setOverride] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
-  if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
+  if (!moderation.blur || override) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -47,6 +48,7 @@ export function ScreenHider({
     )
   }
 
+  const desc = describeModerationCause(moderation.cause, 'account')
   return (
     <View style={[styles.container, pal.view, containerStyle]}>
       <View style={styles.iconContainer}>
@@ -63,11 +65,38 @@ export function ScreenHider({
       </Text>
       <Text type="2xl" style={[styles.description, pal.textLight]}>
         This {screenDescription} has been flagged:{' '}
-        {moderation.reason || 'Content warning'}
+        <Text type="2xl-medium" style={pal.text}>
+          {desc.name}
+        </Text>
+        .{' '}
+        <TouchableWithoutFeedback
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'account',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            Learn More
+          </Text>
+        </TouchableWithoutFeedback>
       </Text>
       {!isDesktopWeb && <View style={styles.spacer} />}
       <View style={styles.btnContainer}>
-        <Button type="inverted" onPress={onPressBack} style={styles.btn}>
+        <Button
+          type="inverted"
+          onPress={() => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Home')
+            }
+          }}
+          style={styles.btn}>
           <Text type="button-lg" style={pal.textInverted}>
             Go back
           </Text>
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 4995562ac..9f11fe48c 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,6 +1,11 @@
 import React from 'react'
-import {StyleProp, StyleSheet, ViewStyle} from 'react-native'
-import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  AppBskyEmbedRecord,
+  AppBskyFeedPost,
+  AppBskyEmbedImages,
+  AppBskyEmbedRecordWithMedia,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
@@ -9,6 +14,55 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/models/ui/shell'
 import {PostEmbeds} from '.'
 import {makeProfileLink} from 'lib/routes/links'
+import {InfoCircleIcon} from 'lib/icons'
+
+export function MaybeQuoteEmbed({
+  embed,
+  style,
+}: {
+  embed: AppBskyEmbedRecord.View
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  if (
+    AppBskyEmbedRecord.isViewRecord(embed.record) &&
+    AppBskyFeedPost.isRecord(embed.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.value).success
+  ) {
+    return (
+      <QuoteEmbed
+        quote={{
+          author: embed.record.author,
+          cid: embed.record.cid,
+          uri: embed.record.uri,
+          indexedAt: embed.record.indexedAt,
+          text: embed.record.value.text,
+          embeds: embed.record.embeds,
+        }}
+        style={style}
+      />
+    )
+  } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Blocked
+        </Text>
+      </View>
+    )
+  } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Deleted
+        </Text>
+      </View>
+    )
+  }
+  return null
+}
 
 export function QuoteEmbed({
   quote,
@@ -76,4 +130,14 @@ const styles = StyleSheet.create({
     paddingLeft: 13,
     paddingRight: 8,
   },
+  errorContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 8,
+    paddingVertical: 14,
+    paddingHorizontal: 14,
+    borderWidth: 1,
+  },
 })
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7ffebff54..627110495 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -12,7 +12,6 @@ import {
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
-  AppBskyFeedPost,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
 } from '@atproto/api'
@@ -24,7 +23,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {YoutubeEmbed} from './YoutubeEmbed'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
-import QuoteEmbed from './QuoteEmbed'
+import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {CustomFeedEmbed} from './CustomFeedEmbed'
 import {ListEmbed} from './ListEmbed'
@@ -49,25 +48,11 @@ export function PostEmbeds({
 
   // quote post with media
   // =
-  if (
-    AppBskyEmbedRecordWithMedia.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
-    AppBskyFeedPost.isRecord(embed.record.record.value) &&
-    AppBskyFeedPost.validateRecord(embed.record.record.value).success
-  ) {
+  if (AppBskyEmbedRecordWithMedia.isView(embed)) {
     return (
       <View style={[styles.stackContainer, style]}>
         <PostEmbeds embed={embed.media} />
-        <QuoteEmbed
-          quote={{
-            author: embed.record.record.author,
-            cid: embed.record.record.cid,
-            uri: embed.record.record.uri,
-            indexedAt: embed.record.record.indexedAt,
-            text: embed.record.record.value.text,
-            embeds: embed.record.record.embeds,
-          }}
-        />
+        <MaybeQuoteEmbed embed={embed.record} />
       </View>
     )
   }
@@ -75,25 +60,7 @@ export function PostEmbeds({
   // quote post
   // =
   if (AppBskyEmbedRecord.isView(embed)) {
-    if (
-      AppBskyEmbedRecord.isViewRecord(embed.record) &&
-      AppBskyFeedPost.isRecord(embed.record.value) &&
-      AppBskyFeedPost.validateRecord(embed.record.value).success
-    ) {
-      return (
-        <QuoteEmbed
-          quote={{
-            author: embed.record.author,
-            cid: embed.record.cid,
-            uri: embed.record.uri,
-            indexedAt: embed.record.indexedAt,
-            text: embed.record.value.text,
-            embeds: embed.record.embeds,
-          }}
-          style={style}
-        />
-      )
-    }
+    return <MaybeQuoteEmbed embed={embed} style={style} />
   }
 
   // image embed
diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index 794195e58..959c6d9ca 100644
--- a/src/view/screens/ModerationBlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -66,7 +66,6 @@ export const ModerationBlockedAccounts = withAuthRequired(
         testID={`blockedAccount-${index}`}
         key={item.did}
         profile={item}
-        overrideModeration
       />
     )
     return (
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 995223c15..c638a55d7 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -63,7 +63,6 @@ export const ModerationMutedAccounts = withAuthRequired(
         testID={`mutedAccount-${index}`}
         key={item.did}
         profile={item}
-        overrideModeration
       />
     )
     return (
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index f00585336..a51fbcf50 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -232,7 +232,7 @@ export const ProfileScreen = withAuthRequired(
             )
           } else if (item instanceof PostsFeedSliceModel) {
             return (
-              <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
+              <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} />
             )
           }
         }
@@ -252,7 +252,7 @@ export const ProfileScreen = withAuthRequired(
         testID="profileView"
         style={styles.container}
         screenDescription="profile"
-        moderation={uiState.profile.moderation.view}>
+        moderation={uiState.profile.moderation.account}>
         {uiState.profile.hasError ? (
           <ErrorScreen
             testID="profileErrorScreen"
diff --git a/yarn.lock b/yarn.lock
index ea1c1e966..3988c3870 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -40,10 +40,10 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@^0.4.3":
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.4.3.tgz#d7e478bf7009df2adaf1ac6051eb3e3fea185c90"
-  integrity sha512-8LdREwmoA58YQDrLS0rohd7cHokhoiXfyYEeNtNlkdO0w/2QpkUCQ1PgPBP2kIRM9PhOEkKp7W3Sn8Te9Qq8jg==
+"@atproto/api@^0.5.2":
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.5.2.tgz#4ba5c57a8737092216e8274ef7ef63384f03d3c6"
+  integrity sha512-AAYz/IL52efZzaIx6Q8wV20MPK3/P5Dpm2qrFeh+rZHaP4KGPJaqLLYnK+SG9ZPQAHtvc18ZftNQJHB2CZsEbw==
   dependencies:
     "@atproto/common-web" "*"
     "@atproto/uri" "*"