about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-04-27 13:15:04 -0500
committerPaul Frazee <pfrazee@gmail.com>2023-04-27 13:15:04 -0500
commit301c2e5bebb2a9077dd0692459a71eadb6c6d1dd (patch)
tree1a7cdd0fd895dd70ade830bc7e9e9ecc3a93b66e
parentda06b608f2992b4a18ca51b8e6919ef4d32aad7a (diff)
parent1d50ddb378d5c6954d4cf8a6145b4486b9497107 (diff)
downloadvoidsky-301c2e5bebb2a9077dd0692459a71eadb6c6d1dd.tar.zst
Merge branch 'main' of github.com:bluesky-social/social-app into main
-rw-r--r--__e2e__/mock-server.ts114
-rw-r--r--app.json2
-rw-r--r--jest/test-pds.ts149
-rw-r--r--package.json4
-rw-r--r--src/lib/labeling/const.ts20
-rw-r--r--src/lib/labeling/helpers.ts303
-rw-r--r--src/lib/labeling/types.ts58
-rw-r--r--src/state/models/content/post-thread.ts22
-rw-r--r--src/state/models/content/post.ts122
-rw-r--r--src/state/models/content/profile.ts18
-rw-r--r--src/state/models/discovery/suggested-posts.ts88
-rw-r--r--src/state/models/feeds/notifications.ts54
-rw-r--r--src/state/models/feeds/posts.ts22
-rw-r--r--src/view/com/discover/SuggestedPosts.tsx66
-rw-r--r--src/view/com/modals/InviteCodes.tsx2
-rw-r--r--src/view/com/notifications/FeedItem.tsx55
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx10
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx10
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx25
-rw-r--r--src/view/com/post/Post.tsx7
-rw-r--r--src/view/com/post/PostText.tsx62
-rw-r--r--src/view/com/posts/FeedItem.tsx12
-rw-r--r--src/view/com/profile/ProfileCard.tsx246
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx10
-rw-r--r--src/view/com/profile/ProfileFollows.tsx10
-rw-r--r--src/view/com/profile/ProfileHeader.tsx8
-rw-r--r--src/view/com/search/SearchResults.tsx10
-rw-r--r--src/view/com/search/Suggestions.tsx34
-rw-r--r--src/view/com/util/PostMeta.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx17
-rw-r--r--src/view/com/util/UserBanner.tsx9
-rw-r--r--src/view/com/util/error/ErrorScreen.tsx2
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx25
-rw-r--r--src/view/com/util/moderation/PostHider.tsx85
-rw-r--r--src/view/com/util/moderation/ProfileHeaderLabels.tsx55
-rw-r--r--src/view/com/util/moderation/ProfileHeaderWarnings.tsx44
-rw-r--r--src/view/com/util/moderation/ScreenHider.tsx129
-rw-r--r--src/view/screens/Profile.tsx9
-rw-r--r--src/view/screens/SearchMobile.tsx21
-rw-r--r--src/view/shell/desktop/Search.tsx9
-rw-r--r--yarn.lock8
41 files changed, 1195 insertions, 763 deletions
diff --git a/__e2e__/mock-server.ts b/__e2e__/mock-server.ts
index 7bcad47f3..858ac5e08 100644
--- a/__e2e__/mock-server.ts
+++ b/__e2e__/mock-server.ts
@@ -63,6 +63,120 @@ async function main() {
             },
           })
         }
+        if ('labels' in url.query) {
+          console.log('Generating naughty users with labels')
+
+          const anchorPost = await server.mocker.createPost(
+            'alice',
+            'Anchor post',
+          )
+
+          for (const user of [
+            'csam-account',
+            'csam-profile',
+            'csam-posts',
+            'porn-account',
+            'porn-profile',
+            'porn-posts',
+            'nudity-account',
+            'nudity-profile',
+            'nudity-posts',
+            'muted-account',
+          ]) {
+            await server.mocker.createUser(user)
+            await server.mocker.follow('alice', user)
+            await server.mocker.follow(user, 'alice')
+            await server.mocker.createPost(user, `Unlabeled post from ${user}`)
+            await server.mocker.createReply(
+              user,
+              `Unlabeled reply from ${user}`,
+              anchorPost,
+            )
+            await server.mocker.like(user, anchorPost)
+          }
+
+          await server.mocker.labelAccount('csam', 'csam-account')
+          await server.mocker.labelProfile('csam', 'csam-profile')
+          await server.mocker.labelPost(
+            'csam',
+            await server.mocker.createPost('csam-posts', 'csam post'),
+          )
+          await server.mocker.labelPost(
+            'csam',
+            await server.mocker.createQuotePost(
+              'csam-posts',
+              'csam quote post',
+              anchorPost,
+            ),
+          )
+          await server.mocker.labelPost(
+            'csam',
+            await server.mocker.createReply(
+              'csam-posts',
+              'csam reply',
+              anchorPost,
+            ),
+          )
+
+          await server.mocker.labelAccount('porn', 'porn-account')
+          await server.mocker.labelProfile('porn', 'porn-profile')
+          await server.mocker.labelPost(
+            'porn',
+            await server.mocker.createPost('porn-posts', 'porn post'),
+          )
+          await server.mocker.labelPost(
+            'porn',
+            await server.mocker.createQuotePost(
+              'porn-posts',
+              'porn quote post',
+              anchorPost,
+            ),
+          )
+          await server.mocker.labelPost(
+            'porn',
+            await server.mocker.createReply(
+              'porn-posts',
+              'porn reply',
+              anchorPost,
+            ),
+          )
+
+          await server.mocker.labelAccount('nudity', 'nudity-account')
+          await server.mocker.labelProfile('nudity', 'nudity-profile')
+          await server.mocker.labelPost(
+            'nudity',
+            await server.mocker.createPost('nudity-posts', 'nudity post'),
+          )
+          await server.mocker.labelPost(
+            'nudity',
+            await server.mocker.createQuotePost(
+              'nudity-posts',
+              'nudity quote post',
+              anchorPost,
+            ),
+          )
+          await server.mocker.labelPost(
+            'nudity',
+            await server.mocker.createReply(
+              'nudity-posts',
+              'nudity reply',
+              anchorPost,
+            ),
+          )
+
+          await server.mocker.users.alice.agent.mute('muted-account.test')
+          await server.mocker.createPost('muted-account', 'muted post')
+          await server.mocker.createQuotePost(
+            'muted-account',
+            'account quote post',
+            anchorPost,
+          )
+          await server.mocker.createReply(
+            'muted-account',
+            'account reply',
+            anchorPost,
+          )
+        }
       }
       console.log('Ready')
       return res.writeHead(200).end(server.pdsUrl)
diff --git a/app.json b/app.json
index 8084f3fff..cae5c0566 100644
--- a/app.json
+++ b/app.json
@@ -3,7 +3,7 @@
     "name": "Bluesky",
     "slug": "bluesky",
     "owner": "blueskysocial",
-    "version": "1.23.0",
+    "version": "1.24.0",
     "orientation": "portrait",
     "icon": "./assets/icon.png",
     "userInterfaceStyle": "light",
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 649638989..7f8d20232 100644
--- a/jest/test-pds.ts
+++ b/jest/test-pds.ts
@@ -2,6 +2,7 @@ import {AddressInfo} from 'net'
 import os from 'os'
 import net from 'net'
 import path from 'path'
+import fs from 'fs'
 import * as crypto from '@atproto/crypto'
 import {PDS, ServerConfig, Database, MemoryBlobStore} from '@atproto/pds'
 import * as plc from '@did-plc/lib'
@@ -104,9 +105,13 @@ export async function createServer(
   await pds.start()
   const pdsUrl = `http://localhost:${port}`
 
+  const profilePic = fs.readFileSync(
+    path.join(__dirname, '..', 'assets', 'default-avatar.jpg'),
+  )
+
   return {
     pdsUrl,
-    mocker: new Mocker(pdsUrl),
+    mocker: new Mocker(pds, pdsUrl, profilePic),
     async close() {
       await pds.destroy()
       await plcServer.destroy()
@@ -118,7 +123,11 @@ class Mocker {
   agent: BskyAgent
   users: Record<string, TestUser> = {}
 
-  constructor(public service: string) {
+  constructor(
+    public pds: PDS,
+    public service: string,
+    public profilePic: Uint8Array,
+  ) {
     this.agent = new BskyAgent({service})
   }
 
@@ -152,6 +161,15 @@ class Mocker {
       handle: name + '.test',
       password: 'hunter2',
     })
+    await agent.upsertProfile(async () => {
+      const blob = await agent.uploadBlob(this.profilePic, {
+        encoding: 'image/jpeg',
+      })
+      return {
+        displayName: name,
+        avatar: blob.data.blob,
+      }
+    })
     this.users[name] = {
       did: res.data.did,
       email,
@@ -192,6 +210,133 @@ class Mocker {
     await this.follow('carla', 'alice')
     await this.follow('carla', 'bob')
   }
+
+  async createPost(user: string, text: string) {
+    const agent = this.users[user]?.agent
+    if (!agent) {
+      throw new Error(`Not a user: ${user}`)
+    }
+    return await agent.post({
+      text,
+      createdAt: new Date().toISOString(),
+    })
+  }
+
+  async createQuotePost(
+    user: string,
+    text: string,
+    {uri, cid}: {uri: string; cid: string},
+  ) {
+    const agent = this.users[user]?.agent
+    if (!agent) {
+      throw new Error(`Not a user: ${user}`)
+    }
+    return await agent.post({
+      text,
+      embed: {$type: 'app.bsky.embed.record', record: {uri, cid}},
+      createdAt: new Date().toISOString(),
+    })
+  }
+
+  async createReply(
+    user: string,
+    text: string,
+    {uri, cid}: {uri: string; cid: string},
+  ) {
+    const agent = this.users[user]?.agent
+    if (!agent) {
+      throw new Error(`Not a user: ${user}`)
+    }
+    return await agent.post({
+      text,
+      reply: {root: {uri, cid}, parent: {uri, cid}},
+      createdAt: new Date().toISOString(),
+    })
+  }
+
+  async like(user: string, {uri, cid}: {uri: string; cid: string}) {
+    const agent = this.users[user]?.agent
+    if (!agent) {
+      throw new Error(`Not a user: ${user}`)
+    }
+    return await agent.like(uri, cid)
+  }
+
+  async labelAccount(label: string, user: string) {
+    const did = this.users[user]?.did
+    if (!did) {
+      throw new Error(`Invalid user: ${user}`)
+    }
+    const ctx = this.pds.ctx
+    if (!ctx) {
+      throw new Error('Invalid PDS')
+    }
+
+    await ctx.db.db
+      .insertInto('label')
+      .values([
+        {
+          src: ctx.cfg.labelerDid,
+          uri: did,
+          cid: '',
+          val: label,
+          neg: 0,
+          cts: new Date().toISOString(),
+        },
+      ])
+      .execute()
+  }
+
+  async labelProfile(label: string, user: string) {
+    const agent = this.users[user]?.agent
+    const did = this.users[user]?.did
+    if (!did) {
+      throw new Error(`Invalid user: ${user}`)
+    }
+
+    const profile = await agent.app.bsky.actor.profile.get({
+      repo: user + '.test',
+      rkey: 'self',
+    })
+
+    const ctx = this.pds.ctx
+    if (!ctx) {
+      throw new Error('Invalid PDS')
+    }
+    await ctx.db.db
+      .insertInto('label')
+      .values([
+        {
+          src: ctx.cfg.labelerDid,
+          uri: profile.uri,
+          cid: profile.cid,
+          val: label,
+          neg: 0,
+          cts: new Date().toISOString(),
+        },
+      ])
+      .execute()
+  }
+
+  async labelPost(label: string, {uri, cid}: {uri: string; cid: string}) {
+    const ctx = this.pds.ctx
+    if (!ctx) {
+      throw new Error('Invalid PDS')
+    }
+    await ctx.db.db
+      .insertInto('label')
+      .values([
+        {
+          src: ctx.cfg.labelerDid,
+          uri,
+          cid,
+          val: label,
+          neg: 0,
+          cts: new Date().toISOString(),
+        },
+      ])
+      .execute()
+  }
 }
 
 const checkAvailablePort = (port: number) =>
diff --git a/package.json b/package.json
index 1d19d609f..939c62b6f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.23.0",
+  "version": "1.24.0",
   "private": true,
   "scripts": {
     "postinstall": "patch-package",
@@ -22,7 +22,7 @@
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "0.2.9",
+    "@atproto/api": "0.2.10",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@expo/webpack-config": "^18.0.1",
diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts
index f68353222..6670e5413 100644
--- a/src/lib/labeling/const.ts
+++ b/src/lib/labeling/const.ts
@@ -1,23 +1,20 @@
 import {LabelPreferencesModel} from 'state/models/ui/preferences'
-
-export interface LabelValGroup {
-  id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
-  title: string
-  subtitle?: string
-  warning?: string
-  values: string[]
-}
+import {LabelValGroup} from './types'
 
 export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
   id: 'illegal',
   title: 'Illegal Content',
+  warning: 'Illegal Content',
   values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
+  imagesOnly: false, // not applicable
 }
 
 export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
   id: 'unknown',
   title: 'Unknown Label',
+  warning: 'Content Warning',
   values: [],
+  imagesOnly: false,
 }
 
 export const CONFIGURABLE_LABEL_GROUPS: Record<
@@ -30,6 +27,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'i.e. Pornography',
     warning: 'Sexually Explicit',
     values: ['porn'],
+    imagesOnly: false, // apply to whole thing
   },
   nudity: {
     id: 'nudity',
@@ -37,6 +35,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'Including non-sexual and artistic',
     warning: 'Nudity',
     values: ['nudity'],
+    imagesOnly: true,
   },
   suggestive: {
     id: 'suggestive',
@@ -44,6 +43,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'Does not include nudity',
     warning: 'Sexually Suggestive',
     values: ['sexual'],
+    imagesOnly: true,
   },
   gore: {
     id: 'gore',
@@ -51,12 +51,14 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'Gore, self-harm, torture',
     warning: 'Violence',
     values: ['gore', 'self-harm', 'torture'],
+    imagesOnly: true,
   },
   hate: {
     id: 'hate',
     title: 'Political Hate-Groups',
     warning: 'Hate',
     values: ['icon-kkk', 'icon-nazi'],
+    imagesOnly: false,
   },
   spam: {
     id: 'spam',
@@ -64,6 +66,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'Excessive low-quality posts',
     warning: 'Spam',
     values: ['spam'],
+    imagesOnly: false,
   },
   impersonation: {
     id: 'impersonation',
@@ -71,5 +74,6 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     subtitle: 'Accounts falsely claiming to be people or orgs',
     warning: 'Impersonation',
     values: ['impersonation'],
+    imagesOnly: false,
   },
 }
diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts
index b2057ff18..bac98c6a2 100644
--- a/src/lib/labeling/helpers.ts
+++ b/src/lib/labeling/helpers.ts
@@ -1,9 +1,33 @@
 import {
-  LabelValGroup,
+  AppBskyActorDefs,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyEmbedRecord,
+  AppBskyFeedPost,
+  AppBskyEmbedImages,
+  AppBskyEmbedExternal,
+} from '@atproto/api'
+import {
   CONFIGURABLE_LABEL_GROUPS,
   ILLEGAL_LABEL_GROUP,
   UNKNOWN_LABEL_GROUP,
 } from './const'
+import {
+  Label,
+  LabelValGroup,
+  ModerationBehaviorCode,
+  PostModeration,
+  ProfileModeration,
+  PostLabelInfo,
+  ProfileLabelInfo,
+} from './types'
+import {RootStoreModel} from 'state/index'
+
+type Embed =
+  | AppBskyEmbedRecord.View
+  | AppBskyEmbedImages.View
+  | AppBskyEmbedExternal.View
+  | AppBskyEmbedRecordWithMedia.View
+  | {$type: string; [k: string]: unknown}
 
 export function getLabelValueGroup(labelVal: string): LabelValGroup {
   let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
@@ -17,3 +41,280 @@ export function getLabelValueGroup(labelVal: string): LabelValGroup {
   }
   return UNKNOWN_LABEL_GROUP
 }
+
+export function getPostModeration(
+  store: RootStoreModel,
+  postInfo: PostLabelInfo,
+): PostModeration {
+  const accountPref = store.preferences.getLabelPreference(
+    postInfo.accountLabels,
+  )
+  const profilePref = store.preferences.getLabelPreference(
+    postInfo.profileLabels,
+  )
+  const postPref = store.preferences.getLabelPreference(postInfo.postLabels)
+
+  // avatar
+  let avatar = {
+    warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
+    blur:
+      accountPref.pref === 'hide' ||
+      accountPref.pref === 'warn' ||
+      profilePref.pref === 'hide' ||
+      profilePref.pref === 'warn',
+  }
+
+  // hide no-override cases
+  if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
+    return hidePostNoOverride(accountPref.desc.warning)
+  }
+  if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
+    return hidePostNoOverride(profilePref.desc.warning)
+  }
+  if (postPref.pref === 'hide' && postPref.desc.id === 'illegal') {
+    return hidePostNoOverride(postPref.desc.warning)
+  }
+
+  // hide cases
+  if (accountPref.pref === 'hide') {
+    return {
+      avatar,
+      list: hide(accountPref.desc.warning),
+      thread: hide(accountPref.desc.warning),
+      view: warn(accountPref.desc.warning),
+    }
+  }
+  if (profilePref.pref === 'hide') {
+    return {
+      avatar,
+      list: hide(profilePref.desc.warning),
+      thread: hide(profilePref.desc.warning),
+      view: warn(profilePref.desc.warning),
+    }
+  }
+  if (postPref.pref === 'hide') {
+    return {
+      avatar,
+      list: hide(postPref.desc.warning),
+      thread: hide(postPref.desc.warning),
+      view: warn(postPref.desc.warning),
+    }
+  }
+
+  // muting
+  if (postInfo.isMuted) {
+    return {
+      avatar,
+      list: hide('Post from an account you muted.'),
+      thread: warn('Post from an account you muted.'),
+      view: warn('Post from an account you muted.'),
+    }
+  }
+
+  // warning cases
+  if (postPref.pref === 'warn') {
+    if (postPref.desc.imagesOnly) {
+      return {
+        avatar,
+        list: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
+        thread: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
+        view: warnContent(postPref.desc.warning), // TODO make warnImages when there's time
+      }
+    }
+    return {
+      avatar,
+      list: warnContent(postPref.desc.warning),
+      thread: warnContent(postPref.desc.warning),
+      view: warnContent(postPref.desc.warning),
+    }
+  }
+  if (accountPref.pref === 'warn') {
+    return {
+      avatar,
+      list: warnContent(accountPref.desc.warning),
+      thread: warnContent(accountPref.desc.warning),
+      view: warnContent(accountPref.desc.warning),
+    }
+  }
+
+  return {
+    avatar,
+    list: show(),
+    thread: show(),
+    view: show(),
+  }
+}
+
+export function getProfileModeration(
+  store: RootStoreModel,
+  profileLabels: ProfileLabelInfo,
+): ProfileModeration {
+  const accountPref = store.preferences.getLabelPreference(
+    profileLabels.accountLabels,
+  )
+  const profilePref = store.preferences.getLabelPreference(
+    profileLabels.profileLabels,
+  )
+
+  // avatar
+  let avatar = {
+    warn: accountPref.pref === 'hide' || accountPref.pref === 'warn',
+    blur:
+      accountPref.pref === 'hide' ||
+      accountPref.pref === 'warn' ||
+      profilePref.pref === 'hide' ||
+      profilePref.pref === 'warn',
+  }
+
+  // hide no-override cases
+  if (accountPref.pref === 'hide' && accountPref.desc.id === 'illegal') {
+    return hideProfileNoOverride(accountPref.desc.warning)
+  }
+  if (profilePref.pref === 'hide' && profilePref.desc.id === 'illegal') {
+    return hideProfileNoOverride(profilePref.desc.warning)
+  }
+
+  // hide cases
+  if (accountPref.pref === 'hide') {
+    return {
+      avatar,
+      list: hide(accountPref.desc.warning),
+      view: hide(accountPref.desc.warning),
+    }
+  }
+  if (profilePref.pref === 'hide') {
+    return {
+      avatar,
+      list: hide(profilePref.desc.warning),
+      view: hide(profilePref.desc.warning),
+    }
+  }
+
+  // warn cases
+  if (accountPref.pref === 'warn') {
+    return {
+      avatar,
+      list: warn(accountPref.desc.warning),
+      view: warn(accountPref.desc.warning),
+    }
+  }
+  // we don't warn for this
+  // if (profilePref.pref === 'warn') {
+  //   return {
+  //     avatar,
+  //     list: warn(profilePref.desc.warning),
+  //     view: warn(profilePref.desc.warning),
+  //   }
+  // }
+
+  return {
+    avatar,
+    list: show(),
+    view: show(),
+  }
+}
+
+export function getProfileViewBasicLabelInfo(
+  profile: AppBskyActorDefs.ProfileViewBasic,
+): ProfileLabelInfo {
+  return {
+    accountLabels: filterAccountLabels(profile.labels),
+    profileLabels: filterProfileLabels(profile.labels),
+    isMuted: profile.viewer?.muted || false,
+  }
+}
+
+export function getEmbedLabels(embed?: Embed): Label[] {
+  if (!embed) {
+    return []
+  }
+  if (
+    AppBskyEmbedRecordWithMedia.isView(embed) &&
+    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+    AppBskyFeedPost.isRecord(embed.record.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.record.value).success
+  ) {
+    return embed.record.record.labels || []
+  }
+  return []
+}
+
+export function filterAccountLabels(labels?: Label[]): Label[] {
+  if (!labels) {
+    return []
+  }
+  return labels.filter(
+    label => !label.uri.endsWith('/app.bsky.actor.profile/self'),
+  )
+}
+
+export function filterProfileLabels(labels?: Label[]): Label[] {
+  if (!labels) {
+    return []
+  }
+  return labels.filter(label =>
+    label.uri.endsWith('/app.bsky.actor.profile/self'),
+  )
+}
+
+// internal methods
+// =
+
+function show() {
+  return {
+    behavior: ModerationBehaviorCode.Show,
+  }
+}
+
+function hidePostNoOverride(reason: string) {
+  return {
+    avatar: {warn: true, blur: true},
+    list: hideNoOverride(reason),
+    thread: hideNoOverride(reason),
+    view: hideNoOverride(reason),
+  }
+}
+
+function hideProfileNoOverride(reason: string) {
+  return {
+    avatar: {warn: true, blur: true},
+    list: hideNoOverride(reason),
+    view: hideNoOverride(reason),
+  }
+}
+
+function hideNoOverride(reason: string) {
+  return {
+    behavior: ModerationBehaviorCode.Hide,
+    reason,
+    noOverride: true,
+  }
+}
+
+function hide(reason: string) {
+  return {
+    behavior: ModerationBehaviorCode.Hide,
+    reason,
+  }
+}
+
+function warn(reason: string) {
+  return {
+    behavior: ModerationBehaviorCode.Warn,
+    reason,
+  }
+}
+
+function warnContent(reason: string) {
+  return {
+    behavior: ModerationBehaviorCode.WarnContent,
+    reason,
+  }
+}
+
+function warnImages(reason: string) {
+  return {
+    behavior: ModerationBehaviorCode.WarnImages,
+    reason,
+  }
+}
diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts
new file mode 100644
index 000000000..d4efb499a
--- /dev/null
+++ b/src/lib/labeling/types.ts
@@ -0,0 +1,58 @@
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {LabelPreferencesModel} from 'state/models/ui/preferences'
+
+export type Label = ComAtprotoLabelDefs.Label
+
+export interface LabelValGroup {
+  id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
+  title: string
+  imagesOnly: boolean
+  subtitle?: string
+  warning: string
+  values: string[]
+}
+
+export interface PostLabelInfo {
+  postLabels: Label[]
+  accountLabels: Label[]
+  profileLabels: Label[]
+  isMuted: boolean
+}
+
+export interface ProfileLabelInfo {
+  accountLabels: Label[]
+  profileLabels: Label[]
+  isMuted: boolean
+}
+
+export enum ModerationBehaviorCode {
+  Show,
+  Hide,
+  Warn,
+  WarnContent,
+  WarnImages,
+}
+
+export interface ModerationBehavior {
+  behavior: ModerationBehaviorCode
+  noOverride?: boolean
+  reason?: string
+}
+
+export interface AvatarModeration {
+  warn: boolean
+  blur: boolean
+}
+
+export interface PostModeration {
+  avatar: AvatarModeration
+  list: ModerationBehavior
+  thread: ModerationBehavior
+  view: ModerationBehavior
+}
+
+export interface ProfileModeration {
+  avatar: AvatarModeration
+  list: ModerationBehavior
+  view: ModerationBehavior
+}
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 76cab5c61..8f9a55032 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -10,6 +10,13 @@ import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {updateDataOptimistically} from 'lib/async/revertible'
+import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
+import {
+  getEmbedLabels,
+  filterAccountLabels,
+  filterProfileLabels,
+  getPostModeration,
+} from 'lib/labeling/helpers'
 
 export class PostThreadItemModel {
   // ui state
@@ -46,6 +53,21 @@ export class PostThreadItemModel {
     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 || false,
+    }
+  }
+
+  get moderation(): PostModeration {
+    return getPostModeration(this.rootStore, this.labelInfo)
+  }
+
   constructor(
     public rootStore: RootStoreModel,
     v: AppBskyFeedDefs.ThreadViewPost,
diff --git a/src/state/models/content/post.ts b/src/state/models/content/post.ts
deleted file mode 100644
index 7ba633366..000000000
--- a/src/state/models/content/post.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {AppBskyFeedPost as Post} from '@atproto/api'
-import {AtUri} from '@atproto/api'
-import {RootStoreModel} from '../root-store'
-import {cleanError} from 'lib/strings/errors'
-
-type RemoveIndex<T> = {
-  [P in keyof T as string extends P
-    ? never
-    : number extends P
-    ? never
-    : P]: T[P]
-}
-export class PostModel implements RemoveIndex<Post.Record> {
-  // state
-  isLoading = false
-  hasLoaded = false
-  error = ''
-  uri: string = ''
-
-  // data
-  text: string = ''
-  entities?: Post.Entity[]
-  reply?: Post.ReplyRef
-  createdAt: string = ''
-
-  constructor(public rootStore: RootStoreModel, uri: string) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        uri: false,
-      },
-      {autoBind: true},
-    )
-    this.uri = uri
-  }
-
-  get hasContent() {
-    return this.createdAt !== ''
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get rootUri(): string {
-    if (this.reply?.root.uri) {
-      return this.reply.root.uri
-    }
-    return this.uri
-  }
-
-  get isThreadMuted() {
-    return this.rootStore.mutedThreads.uris.has(this.rootUri)
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    await this._load()
-  }
-
-  async toggleThreadMute() {
-    if (this.isThreadMuted) {
-      this.rootStore.mutedThreads.uris.delete(this.rootUri)
-    } else {
-      this.rootStore.mutedThreads.uris.add(this.rootUri)
-    }
-  }
-
-  // state transitions
-  // =
-
-  _xLoading() {
-    this.isLoading = true
-    this.error = ''
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      this.rootStore.log.error('Failed to fetch post', err)
-    }
-  }
-
-  // loader functions
-  // =
-
-  async _load() {
-    this._xLoading()
-    try {
-      const urip = new AtUri(this.uri)
-      const res = await this.rootStore.agent.getPost({
-        repo: urip.host,
-        rkey: urip.rkey,
-      })
-      // TODO
-      // if (!res.valid) {
-      //   throw new Error(res.error)
-      // }
-      this._replaceAll(res.value)
-      this._xIdle()
-    } catch (e: any) {
-      this._xIdle(e)
-    }
-  }
-
-  _replaceAll(res: Post.Record) {
-    this.text = res.text
-    this.entities = res.entities
-    this.reply = res.reply
-    this.createdAt = res.createdAt
-  }
-}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index c26dc8749..ea75d19c6 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -10,6 +10,12 @@ 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'
 
 export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
 
@@ -75,6 +81,18 @@ 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,
+    }
+  }
+
+  get moderation(): ProfileModeration {
+    return getProfileModeration(this.rootStore, this.labelInfo)
+  }
+
   // public api
   // =
 
diff --git a/src/state/models/discovery/suggested-posts.ts b/src/state/models/discovery/suggested-posts.ts
deleted file mode 100644
index 6c8de3023..000000000
--- a/src/state/models/discovery/suggested-posts.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {RootStoreModel} from '../root-store'
-import {PostsFeedItemModel} from '../feeds/posts'
-import {cleanError} from 'lib/strings/errors'
-import {TEAM_HANDLES} from 'lib/constants'
-import {
-  getMultipleAuthorsPosts,
-  mergePosts,
-} from 'lib/api/build-suggested-posts'
-
-export class SuggestedPostsModel {
-  // state
-  isLoading = false
-  hasLoaded = false
-  error = ''
-
-  // data
-  posts: PostsFeedItemModel[] = []
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-      },
-      {autoBind: true},
-    )
-  }
-
-  get hasContent() {
-    return this.posts.length > 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  // public api
-  // =
-
-  async setup() {
-    this._xLoading()
-    try {
-      const responses = await getMultipleAuthorsPosts(
-        this.rootStore,
-        TEAM_HANDLES(String(this.rootStore.agent.service)),
-        undefined,
-        30,
-      )
-      runInAction(() => {
-        const finalPosts = mergePosts(responses, {repostsOnly: true})
-        // hydrate into models
-        this.posts = finalPosts.map((post, i) => {
-          // strip the reasons to hide that these are reposts
-          delete post.reason
-          return new PostsFeedItemModel(this.rootStore, `post-${i}`, post)
-        })
-      })
-      this._xIdle()
-    } catch (e: any) {
-      this.rootStore.log.error('SuggestedPostsView: Failed to load posts', {
-        e,
-      })
-      this._xIdle() // dont bubble to the user
-    }
-  }
-
-  // state transitions
-  // =
-
-  _xLoading() {
-    this.isLoading = true
-    this.error = ''
-  }
-
-  _xIdle(err?: any) {
-    this.isLoading = false
-    this.hasLoaded = true
-    this.error = cleanError(err)
-    if (err) {
-      this.rootStore.log.error('Failed to fetch suggested posts', err)
-    }
-  }
-}
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 220e04bce..02f58819f 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -15,6 +15,16 @@ 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
@@ -90,6 +100,24 @@ 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,
+    }
+  }
+
+  get moderation(): PostModeration {
+    return getPostModeration(this.rootStore, this.labelInfo)
+  }
+
   get numUnreadInGroup(): number {
     if (this.additional?.length) {
       return (
@@ -520,16 +548,22 @@ export class NotificationsFeedModel {
   _filterNotifications(
     items: NotificationsFeedItemModel[],
   ): NotificationsFeedItemModel[] {
-    return items.filter(item => {
-      const hideByLabel =
-        this.rootStore.preferences.getLabelPreference(item.labels).pref ===
-        'hide'
-      let mutedThread = !!(
-        item.reasonSubjectRootUri &&
-        this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
-      )
-      return !hideByLabel && !mutedThread
-    })
+    return items
+      .filter(item => {
+        const hideByLabel =
+          item.moderation.list.behavior === ModerationBehaviorCode.Hide
+        let mutedThread = !!(
+          item.reasonSubjectRootUri &&
+          this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
+        )
+        return !hideByLabel && !mutedThread
+      })
+      .map(item => {
+        if (item.additional?.length) {
+          item.additional = this._filterNotifications(item.additional)
+        }
+        return item
+      })
   }
 
   async _fetchItemModels(
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index cbff707d0..62c6da3de 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -20,6 +20,13 @@ import {
 } from 'lib/api/build-suggested-posts'
 import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
 import {updateDataOptimistically} from 'lib/async/revertible'
+import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
+import {
+  getEmbedLabels,
+  getPostModeration,
+  filterAccountLabels,
+  filterProfileLabels,
+} from 'lib/labeling/helpers'
 
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@@ -83,6 +90,21 @@ 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 || false,
+    }
+  }
+
+  get moderation(): PostModeration {
+    return getPostModeration(this.rootStore, this.labelInfo)
+  }
+
   copy(v: FeedViewPost) {
     this.post = v.post
     this.reply = v.reply
diff --git a/src/view/com/discover/SuggestedPosts.tsx b/src/view/com/discover/SuggestedPosts.tsx
deleted file mode 100644
index 6d2f39636..000000000
--- a/src/view/com/discover/SuggestedPosts.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {useStores} from 'state/index'
-import {SuggestedPostsModel} from 'state/models/discovery/suggested-posts'
-import {s} from 'lib/styles'
-import {FeedItem as Post} from '../posts/FeedItem'
-import {Text} from '../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-
-export const SuggestedPosts = observer(() => {
-  const pal = usePalette('default')
-  const store = useStores()
-  const suggestedPostsView = React.useMemo<SuggestedPostsModel>(
-    () => new SuggestedPostsModel(store),
-    [store],
-  )
-
-  React.useEffect(() => {
-    if (!suggestedPostsView.hasLoaded) {
-      suggestedPostsView.setup()
-    }
-  }, [store, suggestedPostsView])
-
-  return (
-    <>
-      {(suggestedPostsView.hasContent || suggestedPostsView.isLoading) && (
-        <Text type="title" style={[styles.heading, pal.text]}>
-          Recently, on Bluesky...
-        </Text>
-      )}
-      {suggestedPostsView.hasContent && (
-        <>
-          <View style={[pal.border, styles.bottomBorder]}>
-            {suggestedPostsView.posts.map(item => (
-              <Post item={item} key={item._reactKey} showFollowBtn />
-            ))}
-          </View>
-        </>
-      )}
-      {suggestedPostsView.isLoading && (
-        <View style={s.mt10}>
-          <ActivityIndicator />
-        </View>
-      )}
-    </>
-  )
-})
-
-const styles = StyleSheet.create({
-  heading: {
-    fontWeight: 'bold',
-    paddingHorizontal: 12,
-    paddingTop: 16,
-    paddingBottom: 8,
-  },
-
-  bottomBorder: {
-    borderBottomWidth: 1,
-  },
-
-  loadMore: {
-    paddingLeft: 12,
-    paddingVertical: 10,
-  },
-})
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index 8d54a50b1..992439ebc 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -57,7 +57,7 @@ export function Component({}: {}) {
         code works once!
       </Text>
       <Text type="sm" style={[styles.description, pal.textLight]}>
-        ( We'll send you more periodically. )
+        ( You'll receive one invite code every two weeks. )
       </Text>
       <ScrollView style={[styles.scrollContainer, pal.border]}>
         {store.me.invites.map((invite, i) => (
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index a5c0ecba0..8a6578a3c 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,7 +8,7 @@ import {
   View,
 } from 'react-native'
 import {AppBskyEmbedImages} from '@atproto/api'
-import {AtUri, ComAtprotoLabelDefs} from '@atproto/api'
+import {AtUri} from '@atproto/api'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -26,8 +26,14 @@ import {UserAvatar} from '../util/UserAvatar'
 import {ImageHorzList} from '../util/images/ImageHorzList'
 import {Post} from '../post/Post'
 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'
 
 const MAX_AUTHORS = 5
 
@@ -38,14 +44,15 @@ interface Author {
   handle: string
   displayName?: string
   avatar?: string
-  labels?: ComAtprotoLabelDefs.Label[]
+  moderation: ProfileModeration
 }
 
-export const FeedItem = observer(function FeedItem({
+export const FeedItem = observer(function ({
   item,
 }: {
   item: NotificationsFeedItemModel
 }) {
+  const store = useStores()
   const pal = usePalette('default')
   const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
   const itemHref = useMemo(() => {
@@ -81,27 +88,25 @@ export const FeedItem = observer(function FeedItem({
         handle: item.author.handle,
         displayName: item.author.displayName,
         avatar: item.author.avatar,
-        labels: item.author.labels,
+        moderation: getProfileModeration(
+          store,
+          getProfileViewBasicLabelInfo(item.author),
+        ),
       },
-      ...(item.additional?.map(
-        ({author: {avatar, labels, handle, displayName}}) => {
-          return {
-            href: `/profile/${handle}`,
-            handle,
-            displayName,
-            avatar,
-            labels,
-          }
-        },
-      ) || []),
+      ...(item.additional?.map(({author}) => {
+        return {
+          href: `/profile/${author.handle}`,
+          handle: author.handle,
+          displayName: author.displayName,
+          avatar: author.avatar,
+          moderation: getProfileModeration(
+            store,
+            getProfileViewBasicLabelInfo(author),
+          ),
+        }
+      }) || []),
     ]
-  }, [
-    item.additional,
-    item.author.avatar,
-    item.author.displayName,
-    item.author.handle,
-    item.author.labels,
-  ])
+  }, [store, item.additional, item.author])
 
   if (item.additionalPost?.notFound) {
     // don't render anything if the target post was deleted or unfindable
@@ -264,7 +269,7 @@ function CondensedAuthorsList({
           <UserAvatar
             size={35}
             avatar={authors[0].avatar}
-            hasWarning={!!authors[0].labels?.length}
+            moderation={authors[0].moderation.avatar}
           />
         </Link>
       </View>
@@ -277,7 +282,7 @@ function CondensedAuthorsList({
           <UserAvatar
             size={35}
             avatar={author.avatar}
-            hasWarning={!!author.labels?.length}
+            moderation={author.moderation.avatar}
           />
         </View>
       ))}
@@ -335,7 +340,7 @@ function ExpandedAuthorsList({
             <UserAvatar
               size={35}
               avatar={author.avatar}
-              hasWarning={!!author.labels?.length}
+              moderation={author.moderation.avatar}
             />
           </View>
           <View style={s.flex1}>
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index dc090e7ad..80dd59072 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -47,15 +47,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
   // loaded
   // =
   const renderItem = ({item}: {item: LikeItem}) => (
-    <ProfileCardWithFollowBtn
-      key={item.actor.did}
-      did={item.actor.did}
-      handle={item.actor.handle}
-      displayName={item.actor.displayName}
-      avatar={item.actor.avatar}
-      labels={item.actor.labels}
-      isFollowedBy={!!item.actor.viewer?.followedBy}
-    />
+    <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
   )
   return (
     <FlatList
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 65579ae23..31fa0cf7f 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -58,15 +58,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
   // loaded
   // =
   const renderItem = ({item}: {item: RepostedByItem}) => (
-    <ProfileCardWithFollowBtn
-      key={item.did}
-      did={item.did}
-      handle={item.handle}
-      displayName={item.displayName}
-      avatar={item.avatar}
-      labels={item.labels}
-      isFollowedBy={!!item.viewer?.followedBy}
-    />
+    <ProfileCardWithFollowBtn key={item.did} profile={item} />
   )
   return (
     <FlatList
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index e779f018e..8fdcce8ad 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -145,21 +145,17 @@ export const PostThreadItem = observer(function PostThreadItem({
 
   if (item._isHighlightedPost) {
     return (
-      <View
+      <PostHider
         testID={`postThreadItem-by-${item.post.author.handle}`}
-        style={[
-          styles.outer,
-          styles.outerHighlighted,
-          {borderTopColor: pal.colors.border},
-          pal.view,
-        ]}>
+        style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
+        moderation={item.moderation.thread}>
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
             <Link href={authorHref} title={authorTitle} asAnchor>
               <UserAvatar
                 size={52}
                 avatar={item.post.author.avatar}
-                hasWarning={!!item.post.author.labels?.length}
+                moderation={item.moderation.avatar}
               />
             </Link>
           </View>
@@ -218,9 +214,7 @@ export const PostThreadItem = observer(function PostThreadItem({
           </View>
         </View>
         <View style={[s.pl10, s.pr10, s.pb10]}>
-          <ContentHider
-            isMuted={item.post.author.viewer?.muted === true}
-            labels={item.post.labels}>
+          <ContentHider moderation={item.moderation.view}>
             {item.richText?.text ? (
               <View
                 style={[
@@ -300,7 +294,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             />
           </View>
         </View>
-      </View>
+      </PostHider>
     )
   } else {
     return (
@@ -309,8 +303,7 @@ export const PostThreadItem = observer(function PostThreadItem({
           testID={`postThreadItem-by-${item.post.author.handle}`}
           href={itemHref}
           style={[styles.outer, {borderColor: pal.colors.border}, pal.view]}
-          isMuted={item.post.author.viewer?.muted === true}
-          labels={item.post.labels}>
+          moderation={item.moderation.thread}>
           {item._showParentReplyLine && (
             <View
               style={[
@@ -333,7 +326,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 <UserAvatar
                   size={52}
                   avatar={item.post.author.avatar}
-                  hasWarning={!!item.post.author.labels?.length}
+                  moderation={item.moderation.avatar}
                 />
               </Link>
             </View>
@@ -347,7 +340,7 @@ export const PostThreadItem = observer(function PostThreadItem({
                 did={item.post.author.did}
               />
               <ContentHider
-                labels={item.post.labels}
+                moderation={item.moderation.thread}
                 containerStyle={styles.contentHider}>
                 {item.richText?.text ? (
                   <View style={styles.postTextContainer}>
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 81f3b8c45..af78a951b 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -206,8 +206,7 @@ const PostLoaded = observer(
       <PostHider
         href={itemHref}
         style={[styles.outer, pal.view, pal.border, style]}
-        isMuted={item.post.author.viewer?.muted === true}
-        labels={item.post.labels}>
+        moderation={item.moderation.list}>
         {showReplyLine && <View style={styles.replyLine} />}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
@@ -215,7 +214,7 @@ const PostLoaded = observer(
               <UserAvatar
                 size={52}
                 avatar={item.post.author.avatar}
-                hasWarning={!!item.post.author.labels?.length}
+                moderation={item.moderation.avatar}
               />
             </Link>
           </View>
@@ -247,7 +246,7 @@ const PostLoaded = observer(
               </View>
             )}
             <ContentHider
-              labels={item.post.labels}
+              moderation={item.moderation.list}
               containerStyle={styles.contentHider}>
               {item.richText?.text ? (
                 <View style={styles.postTextContainer}>
diff --git a/src/view/com/post/PostText.tsx b/src/view/com/post/PostText.tsx
deleted file mode 100644
index 1a56a5dbf..000000000
--- a/src/view/com/post/PostText.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React, {useState, useEffect} from 'react'
-import {observer} from 'mobx-react-lite'
-import {StyleProp, StyleSheet, TextStyle, View} from 'react-native'
-import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {Text} from '../util/text/Text'
-import {PostModel} from 'state/models/content/post'
-import {useStores} from 'state/index'
-
-export const PostText = observer(function PostText({
-  uri,
-  style,
-}: {
-  uri: string
-  style?: StyleProp<TextStyle>
-}) {
-  const store = useStores()
-  const [model, setModel] = useState<PostModel | undefined>()
-
-  useEffect(() => {
-    if (model?.uri === uri) {
-      return // no change needed? or trigger refresh?
-    }
-    const newModel = new PostModel(store, uri)
-    setModel(newModel)
-    newModel.setup().catch(err => store.log.error('Failed to fetch post', err))
-  }, [uri, model?.uri, store])
-
-  // loading
-  // =
-  if (!model || model.isLoading || model.uri !== uri) {
-    return (
-      <View>
-        <LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
-        <LoadingPlaceholder width="100%" height={8} style={styles.mt6} />
-        <LoadingPlaceholder width={100} height={8} style={styles.mt6} />
-      </View>
-    )
-  }
-
-  // error
-  // =
-  if (model.hasError) {
-    return (
-      <View>
-        <ErrorMessage style={style} message={model.error} />
-      </View>
-    )
-  }
-
-  // loaded
-  // =
-  return (
-    <View>
-      <Text style={style}>{model.text}</Text>
-    </View>
-  )
-})
-
-const styles = StyleSheet.create({
-  mt6: {marginTop: 6},
-})
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 18481d4cb..10fc775c5 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -30,14 +30,13 @@ export const FeedItem = observer(function ({
   isThreadChild,
   isThreadParent,
   showFollowBtn,
-  ignoreMuteFor,
 }: {
   item: PostsFeedItemModel
   isThreadChild?: boolean
   isThreadParent?: boolean
   showReplyLine?: boolean
   showFollowBtn?: boolean
-  ignoreMuteFor?: string
+  ignoreMuteFor?: string // NOTE currently disabled, will be addressed in the next PR -prf
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -134,8 +133,6 @@ export const FeedItem = observer(function ({
   }
 
   const isSmallTop = isThreadChild
-  const isMuted =
-    item.post.author.viewer?.muted && ignoreMuteFor !== item.post.author.did
   const outerStyles = [
     styles.outer,
     pal.view,
@@ -149,8 +146,7 @@ export const FeedItem = observer(function ({
       testID={`feedItem-by-${item.post.author.handle}`}
       style={outerStyles}
       href={itemHref}
-      isMuted={isMuted}
-      labels={item.post.labels}>
+      moderation={item.moderation.list}>
       {isThreadChild && (
         <View
           style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
@@ -200,7 +196,7 @@ export const FeedItem = observer(function ({
             <UserAvatar
               size={52}
               avatar={item.post.author.avatar}
-              hasWarning={!!item.post.author.labels?.length}
+              moderation={item.moderation.avatar}
             />
           </Link>
         </View>
@@ -236,7 +232,7 @@ export const FeedItem = observer(function ({
             </View>
           )}
           <ContentHider
-            labels={item.post.labels}
+            moderation={item.moderation.list}
             containerStyle={styles.contentHider}>
             {item.richText?.text ? (
               <View style={styles.postTextContainer}>
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 07bf4e291..154344388 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api'
+import {AppBskyActorDefs} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -10,143 +10,159 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {FollowButton} from './FollowButton'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {
+  getProfileViewBasicLabelInfo,
+  getProfileModeration,
+} from 'lib/labeling/helpers'
+import {ModerationBehaviorCode} from 'lib/labeling/types'
 
-export function ProfileCard({
-  testID,
-  handle,
-  displayName,
-  avatar,
-  description,
-  labels,
-  isFollowedBy,
-  noBg,
-  noBorder,
-  followers,
-  renderButton,
-}: {
-  testID?: string
-  handle: string
-  displayName?: string
-  avatar?: string
-  description?: string
-  labels: ComAtprotoLabelDefs.Label[] | undefined
-  isFollowedBy?: boolean
-  noBg?: boolean
-  noBorder?: boolean
-  followers?: AppBskyActorDefs.ProfileView[] | undefined
-  renderButton?: () => JSX.Element
-}) {
-  const pal = usePalette('default')
-  return (
-    <Link
-      testID={testID}
-      style={[
-        styles.outer,
-        pal.border,
-        noBorder && styles.outerNoBorder,
-        !noBg && pal.view,
-      ]}
-      href={`/profile/${handle}`}
-      title={handle}
-      asAnchor>
-      <View style={styles.layout}>
-        <View style={styles.layoutAvi}>
-          <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} />
-        </View>
-        <View style={styles.layoutContent}>
-          <Text
-            type="lg"
-            style={[s.bold, pal.text]}
-            numberOfLines={1}
-            lineHeight={1.2}>
-            {sanitizeDisplayName(displayName || handle)}
-          </Text>
-          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-            @{handle}
-          </Text>
-          {isFollowedBy && (
-            <View style={s.flexRow}>
-              <View style={[s.mt5, pal.btn, styles.pill]}>
-                <Text type="xs" style={pal.text}>
-                  Follows You
-                </Text>
+export const ProfileCard = observer(
+  ({
+    testID,
+    profile,
+    noBg,
+    noBorder,
+    followers,
+    renderButton,
+  }: {
+    testID?: string
+    profile: AppBskyActorDefs.ProfileViewBasic
+    noBg?: boolean
+    noBorder?: boolean
+    followers?: AppBskyActorDefs.ProfileView[] | undefined
+    renderButton?: () => JSX.Element
+  }) => {
+    const store = useStores()
+    const pal = usePalette('default')
+
+    const moderation = getProfileModeration(
+      store,
+      getProfileViewBasicLabelInfo(profile),
+    )
+
+    if (moderation.list.behavior === ModerationBehaviorCode.Hide) {
+      return null
+    }
+
+    return (
+      <Link
+        testID={testID}
+        style={[
+          styles.outer,
+          pal.border,
+          noBorder && styles.outerNoBorder,
+          !noBg && pal.view,
+        ]}
+        href={`/profile/${profile.handle}`}
+        title={profile.handle}
+        asAnchor>
+        <View style={styles.layout}>
+          <View style={styles.layoutAvi}>
+            <UserAvatar
+              size={40}
+              avatar={profile.avatar}
+              moderation={moderation.avatar}
+            />
+          </View>
+          <View style={styles.layoutContent}>
+            <Text
+              type="lg"
+              style={[s.bold, pal.text]}
+              numberOfLines={1}
+              lineHeight={1.2}>
+              {sanitizeDisplayName(profile.displayName || profile.handle)}
+            </Text>
+            <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+              @{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>
-            </View>
-          )}
+            )}
+          </View>
+          {renderButton ? (
+            <View style={styles.layoutButton}>{renderButton()}</View>
+          ) : undefined}
         </View>
-        {renderButton ? (
-          <View style={styles.layoutButton}>{renderButton()}</View>
+        {profile.description ? (
+          <View style={styles.details}>
+            <Text style={pal.text} numberOfLines={4}>
+              {profile.description}
+            </Text>
+          </View>
         ) : undefined}
-      </View>
-      {description ? (
-        <View style={styles.details}>
-          <Text style={pal.text} numberOfLines={4}>
-            {description}
-          </Text>
-        </View>
-      ) : undefined}
-      {followers?.length ? (
-        <View style={styles.followedBy}>
-          <Text
-            type="sm"
-            style={[styles.followsByDesc, pal.textLight]}
-            numberOfLines={2}
-            lineHeight={1.2}>
-            Followed by{' '}
-            {followers.map(f => f.displayName || f.handle).join(', ')}
-          </Text>
-          {followers.slice(0, 3).map(f => (
-            <View key={f.did} style={styles.followedByAviContainer}>
-              <View style={[styles.followedByAvi, pal.view]}>
-                <UserAvatar avatar={f.avatar} size={32} />
-              </View>
+        <FollowersList followers={followers} />
+      </Link>
+    )
+  },
+)
+
+const FollowersList = observer(
+  ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    if (!followers?.length) {
+      return null
+    }
+
+    const followersWithMods = followers
+      .map(f => ({
+        f,
+        mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
+      }))
+      .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
+
+    return (
+      <View style={styles.followedBy}>
+        <Text
+          type="sm"
+          style={[styles.followsByDesc, pal.textLight]}
+          numberOfLines={2}
+          lineHeight={1.2}>
+          Followed by{' '}
+          {followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
+        </Text>
+        {followersWithMods.slice(0, 3).map(({f, mod}) => (
+          <View key={f.did} style={styles.followedByAviContainer}>
+            <View style={[styles.followedByAvi, pal.view]}>
+              <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
             </View>
-          ))}
-        </View>
-      ) : undefined}
-    </Link>
-  )
-}
+          </View>
+        ))}
+      </View>
+    )
+  },
+)
 
 export const ProfileCardWithFollowBtn = observer(
   ({
-    did,
-    handle,
-    displayName,
-    avatar,
-    description,
-    labels,
-    isFollowedBy,
+    profile,
     noBg,
     noBorder,
     followers,
   }: {
-    did: string
-    handle: string
-    displayName?: string
-    avatar?: string
-    description?: string
-    labels: ComAtprotoLabelDefs.Label[] | undefined
-    isFollowedBy?: boolean
+    profile: AppBskyActorDefs.ProfileViewBasic
     noBg?: boolean
     noBorder?: boolean
     followers?: AppBskyActorDefs.ProfileView[] | undefined
   }) => {
     const store = useStores()
-    const isMe = store.me.handle === handle
+    const isMe = store.me.handle === profile.handle
 
     return (
       <ProfileCard
-        handle={handle}
-        displayName={displayName}
-        avatar={avatar}
-        description={description}
-        labels={labels}
-        isFollowedBy={isFollowedBy}
+        profile={profile}
         noBg={noBg}
         noBorder={noBorder}
         followers={followers}
-        renderButton={isMe ? undefined : () => <FollowButton did={did} />}
+        renderButton={
+          isMe ? undefined : () => <FollowButton did={profile.did} />
+        }
       />
     )
   },
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index cba171925..aeb2fcba9 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -61,15 +61,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
   // loaded
   // =
   const renderItem = ({item}: {item: FollowerItem}) => (
-    <ProfileCardWithFollowBtn
-      key={item.did}
-      did={item.did}
-      handle={item.handle}
-      displayName={item.displayName}
-      avatar={item.avatar}
-      labels={item.labels}
-      isFollowedBy={!!item.viewer?.followedBy}
-    />
+    <ProfileCardWithFollowBtn key={item.did} profile={item} />
   )
   return (
     <FlatList
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index dafba62fc..0632fac02 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -58,15 +58,7 @@ export const ProfileFollows = observer(function ProfileFollows({
   // loaded
   // =
   const renderItem = ({item}: {item: FollowItem}) => (
-    <ProfileCardWithFollowBtn
-      key={item.did}
-      did={item.did}
-      handle={item.handle}
-      displayName={item.displayName}
-      avatar={item.avatar}
-      labels={item.labels}
-      isFollowedBy={!!item.viewer?.followedBy}
-    />
+    <ProfileCardWithFollowBtn key={item.did} profile={item} />
   )
   return (
     <FlatList
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index c295b2716..d1104d184 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -26,7 +26,7 @@ import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
-import {ProfileHeaderLabels} from '../util/moderation/ProfileHeaderLabels'
+import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics'
 import {NavigationProp} from 'lib/routes/types'
@@ -219,7 +219,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
   ])
   return (
     <View style={pal.view}>
-      <UserBanner banner={view.banner} />
+      <UserBanner banner={view.banner} moderation={view.moderation.avatar} />
       <View style={styles.content}>
         <View style={[styles.buttonsLine]}>
           {isMe ? (
@@ -332,7 +332,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
             richText={view.descriptionRichText}
           />
         ) : undefined}
-        <ProfileHeaderLabels labels={view.labels} />
+        <ProfileHeaderWarnings moderation={view.moderation.view} />
         {view.viewer.muted ? (
           <View
             testID="profileHeaderMutedNotice"
@@ -364,7 +364,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
           <UserAvatar
             size={80}
             avatar={view.avatar}
-            hasWarning={!!view.labels?.length}
+            moderation={view.moderation.avatar}
           />
         </View>
       </TouchableWithoutFeedback>
diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx
index 3b05f75ea..ca6a0dba2 100644
--- a/src/view/com/search/SearchResults.tsx
+++ b/src/view/com/search/SearchResults.tsx
@@ -99,15 +99,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
   return (
     <ScrollView style={pal.view}>
       {model.profiles.map(item => (
-        <ProfileCardWithFollowBtn
-          key={item.did}
-          did={item.did}
-          handle={item.handle}
-          displayName={item.displayName}
-          avatar={item.avatar}
-          description={item.description}
-          labels={item.labels}
-        />
+        <ProfileCardWithFollowBtn key={item.did} profile={item} />
       ))}
       <View style={s.footerSpacer} />
       <View style={s.footerSpacer} />
diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx
index aacab5c98..ead17f72e 100644
--- a/src/view/com/search/Suggestions.tsx
+++ b/src/view/com/search/Suggestions.tsx
@@ -144,18 +144,9 @@ export const Suggestions = observer(
               <View style={[styles.card, pal.view, pal.border]}>
                 <ProfileCardWithFollowBtn
                   key={item.ref.did}
-                  did={item.ref.did}
-                  handle={item.ref.handle}
-                  displayName={item.ref.displayName}
-                  avatar={item.ref.avatar}
-                  labels={item.ref.labels}
+                  profile={item.ref}
                   noBg
                   noBorder
-                  description={
-                    item.ref.description
-                      ? (item.ref as AppBskyActorDefs.ProfileView).description
-                      : ''
-                  }
                   followers={
                     item.ref.followers
                       ? (item.ref.followers as AppBskyActorDefs.ProfileView[])
@@ -170,18 +161,9 @@ export const Suggestions = observer(
               <View style={[styles.card, pal.view, pal.border]}>
                 <ProfileCardWithFollowBtn
                   key={item.view.did}
-                  did={item.view.did}
-                  handle={item.view.handle}
-                  displayName={item.view.displayName}
-                  avatar={item.view.avatar}
-                  labels={item.view.labels}
+                  profile={item.view}
                   noBg
                   noBorder
-                  description={
-                    item.view.description
-                      ? (item.view as AppBskyActorDefs.ProfileView).description
-                      : ''
-                  }
                 />
               </View>
             )
@@ -191,19 +173,9 @@ export const Suggestions = observer(
               <View style={[styles.card, pal.view, pal.border]}>
                 <ProfileCardWithFollowBtn
                   key={item.suggested.did}
-                  did={item.suggested.did}
-                  handle={item.suggested.handle}
-                  displayName={item.suggested.displayName}
-                  avatar={item.suggested.avatar}
-                  labels={item.suggested.labels}
+                  profile={item.suggested}
                   noBg
                   noBorder
-                  description={
-                    item.suggested.description
-                      ? (item.suggested as AppBskyActorDefs.ProfileView)
-                          .description
-                      : ''
-                  }
                 />
               </View>
             )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index d9dd11e05..45651e4e5 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -97,7 +97,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
           <UserAvatar
             avatar={opts.authorAvatar}
             size={16}
-            hasWarning={opts.authorHasWarning}
+            // TODO moderation
           />
         </View>
       )}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 9c0fe9297..7f55bf773 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -13,8 +13,11 @@ import {useStores} from 'state/index'
 import {colors} from 'lib/styles'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
+import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
+import {AvatarModeration} from 'lib/labeling/types'
+
+const BLUR_AMOUNT = isWeb ? 5 : 100
 
 function DefaultAvatar({size}: {size: number}) {
   return (
@@ -40,12 +43,12 @@ function DefaultAvatar({size}: {size: number}) {
 export function UserAvatar({
   size,
   avatar,
-  hasWarning,
+  moderation,
   onSelectNewAvatar,
 }: {
   size: number
   avatar?: string | null
-  hasWarning?: boolean
+  moderation?: AvatarModeration
   onSelectNewAvatar?: (img: RNImage | null) => void
 }) {
   const store = useStores()
@@ -114,7 +117,7 @@ export function UserAvatar({
   )
 
   const warning = useMemo(() => {
-    if (!hasWarning) {
+    if (!moderation?.warn) {
       return null
     }
     return (
@@ -126,7 +129,7 @@ export function UserAvatar({
         />
       </View>
     )
-  }, [hasWarning, size, pal])
+  }, [moderation?.warn, size, pal])
 
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
@@ -159,13 +162,15 @@ export function UserAvatar({
         />
       </View>
     </DropdownButton>
-  ) : avatar ? (
+  ) : avatar &&
+    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
     <View style={{width: size, height: size}}>
       <HighPriorityImage
         testID="userAvatarImage"
         style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
         contentFit="cover"
         source={{uri: avatar}}
+        blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
       />
       {warning}
     </View>
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index fcd66ca7a..14459bf77 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -13,13 +13,16 @@ import {
 } from 'lib/hooks/usePermissions'
 import {DropdownButton} from './forms/DropdownButton'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
+import {AvatarModeration} from 'lib/labeling/types'
+import {isWeb, isAndroid} from 'platform/detection'
 
 export function UserBanner({
   banner,
+  moderation,
   onSelectNewBanner,
 }: {
   banner?: string | null
+  moderation?: AvatarModeration
   onSelectNewBanner?: (img: TImage | null) => void
 }) {
   const store = useStores()
@@ -107,12 +110,14 @@ export function UserBanner({
         />
       </View>
     </DropdownButton>
-  ) : banner ? (
+  ) : banner &&
+    !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
     <Image
       testID="userBannerImage"
       style={styles.bannerImage}
       resizeMode="cover"
       source={{uri: banner}}
+      blurRadius={moderation?.blur ? 100 : 0}
     />
   ) : (
     <View
diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx
index dee625967..c849e37db 100644
--- a/src/view/com/util/error/ErrorScreen.tsx
+++ b/src/view/com/util/error/ErrorScreen.tsx
@@ -35,7 +35,7 @@ export function ErrorScreen({
           ]}>
           <FontAwesomeIcon
             icon="exclamation"
-            style={pal.textInverted}
+            style={pal.textInverted as FontAwesomeIconStyle}
             size={24}
           />
         </View>
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index 42a97cd34..74fb479ad 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -6,32 +6,31 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {ComAtprotoLabelDefs} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
-import {useStores} from 'state/index'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
+import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
 
 export function ContentHider({
   testID,
-  isMuted,
-  labels,
+  moderation,
   style,
   containerStyle,
   children,
 }: React.PropsWithChildren<{
   testID?: string
-  isMuted?: boolean
-  labels: ComAtprotoLabelDefs.Label[] | undefined
+  moderation: ModerationBehavior
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const store = useStores()
-  const labelPref = store.preferences.getLabelPreference(labels)
 
-  if (!isMuted && labelPref.pref === 'show') {
+  if (
+    moderation.behavior === ModerationBehaviorCode.Show ||
+    moderation.behavior === ModerationBehaviorCode.Warn ||
+    moderation.behavior === ModerationBehaviorCode.WarnImages
+  ) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -39,7 +38,7 @@ export function ContentHider({
     )
   }
 
-  if (labelPref.pref === 'hide') {
+  if (moderation.behavior === ModerationBehaviorCode.Hide) {
     return null
   }
 
@@ -52,11 +51,7 @@ export function ContentHider({
           override && styles.descriptionOpen,
         ]}>
         <Text type="md" style={pal.textLight}>
-          {isMuted ? (
-            <>Post from an account you muted.</>
-          ) : (
-            <>Warning: {labelPref.desc.warning || labelPref.desc.title}</>
-          )}
+          {moderation.reason || 'Content warning'}
         </Text>
         <TouchableOpacity
           style={styles.showBtn}
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index bafc7aecf..b3c4c9593 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -6,77 +6,72 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-import {ComAtprotoLabelDefs} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
-import {useStores} from 'state/index'
+import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
 
 export function PostHider({
   testID,
   href,
-  isMuted,
-  labels,
+  moderation,
   style,
   children,
 }: React.PropsWithChildren<{
   testID?: string
-  href: string
-  isMuted: boolean | undefined
-  labels: ComAtprotoLabelDefs.Label[] | undefined
+  href?: string
+  moderation: ModerationBehavior
   style: StyleProp<ViewStyle>
 }>) {
-  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
   const bg = override ? pal.viewLight : pal.view
 
-  const labelPref = store.preferences.getLabelPreference(labels)
-  if (labelPref.pref === 'hide') {
-    return <></>
+  if (moderation.behavior === ModerationBehaviorCode.Hide) {
+    return null
   }
 
-  if (!isMuted) {
-    // NOTE: any further label enforcement should occur in ContentContainer
+  if (moderation.behavior === ModerationBehaviorCode.Warn) {
     return (
-      <Link testID={testID} style={style} href={href} noFeedback>
-        {children}
-      </Link>
+      <>
+        <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)}>
+            <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>
+        )}
+      </>
     )
   }
 
+  // NOTE: any further label enforcement should occur in ContentContainer
   return (
-    <>
-      <View style={[styles.description, bg, pal.border]}>
-        <FontAwesomeIcon
-          icon={['far', 'eye-slash']}
-          style={[styles.icon, pal.text]}
-        />
-        <Text type="md" style={pal.textLight}>
-          Post from an account you muted.
-        </Text>
-        <TouchableOpacity
-          style={styles.showBtn}
-          onPress={() => setOverride(v => !v)}>
-          <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>
+      {children}
+    </Link>
   )
 }
 
diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx
deleted file mode 100644
index c6fbfaf6b..000000000
--- a/src/view/com/util/moderation/ProfileHeaderLabels.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {getLabelValueGroup} from 'lib/labeling/helpers'
-
-export function ProfileHeaderLabels({
-  labels,
-}: {
-  labels: ComAtprotoLabelDefs.Label[] | undefined
-}) {
-  const palErr = usePalette('error')
-  if (!labels?.length) {
-    return null
-  }
-  return (
-    <>
-      {labels.map((label, i) => {
-        const labelGroup = getLabelValueGroup(label?.val || '')
-        return (
-          <View
-            key={`${label.val}-${i}`}
-            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 for{' '}
-              {(labelGroup.warning || labelGroup.title).toLocaleLowerCase()}.
-            </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/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
new file mode 100644
index 000000000..7a1a8e295
--- /dev/null
+++ b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 000000000..2e7b07e1a
--- /dev/null
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -0,0 +1,129 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+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'
+
+export function ScreenHider({
+  testID,
+  screenDescription,
+  moderation,
+  style,
+  containerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  screenDescription: string
+  moderation: ModerationBehavior
+  style?: StyleProp<ViewStyle>
+  containerStyle?: StyleProp<ViewStyle>
+}>) {
+  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) {
+    return (
+      <View testID={testID} style={style}>
+        {children}
+      </View>
+    )
+  }
+
+  return (
+    <View style={[styles.container, pal.view, containerStyle]}>
+      <View style={styles.iconContainer}>
+        <View style={[styles.icon, palInverted.view]}>
+          <FontAwesomeIcon
+            icon="exclamation"
+            style={pal.textInverted as FontAwesomeIconStyle}
+            size={24}
+          />
+        </View>
+      </View>
+      <Text type="title-2xl" style={[styles.title, pal.text]}>
+        Content Warning
+      </Text>
+      <Text type="2xl" style={[styles.description, pal.textLight]}>
+        This {screenDescription} has been flagged:{' '}
+        {moderation.reason || 'Content warning'}
+      </Text>
+      {!isDesktopWeb && <View style={styles.spacer} />}
+      <View style={styles.btnContainer}>
+        <Button type="inverted" onPress={onPressBack} style={styles.btn}>
+          <Text type="button-lg" style={pal.textInverted}>
+            Go back
+          </Text>
+        </Button>
+        {!moderation.noOverride && (
+          <Button
+            type="default"
+            onPress={() => setOverride(v => !v)}
+            style={styles.btn}>
+            <Text type="button-lg" style={pal.text}>
+              Show anyway
+            </Text>
+          </Button>
+        )}
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  spacer: {
+    flex: 1,
+  },
+  container: {
+    flex: 1,
+    paddingTop: 100,
+    paddingBottom: 150,
+  },
+  iconContainer: {
+    alignItems: 'center',
+    marginBottom: 10,
+  },
+  icon: {
+    borderRadius: 25,
+    width: 50,
+    height: 50,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  title: {
+    textAlign: 'center',
+    marginBottom: 10,
+  },
+  description: {
+    marginBottom: 10,
+    paddingHorizontal: 20,
+    textAlign: 'center',
+  },
+  btnContainer: {
+    flexDirection: 'row',
+    justifyContent: 'center',
+    marginVertical: 10,
+    gap: 10,
+  },
+  btn: {
+    paddingHorizontal: 20,
+    paddingVertical: 14,
+  },
+})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 4e4e3040b..4be117932 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -6,6 +6,7 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewSelector} from '../com/util/ViewSelector'
 import {CenteredView} from '../com/util/Views'
+import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
 import {ProfileUiModel} from 'state/models/ui/profile'
 import {useStores} from 'state/index'
 import {PostsFeedSliceModel} from 'state/models/feeds/posts'
@@ -140,7 +141,11 @@ export const ProfileScreen = withAuthRequired(
     )
 
     return (
-      <View testID="profileView" style={styles.container}>
+      <ScreenHider
+        testID="profileView"
+        style={styles.container}
+        screenDescription="profile"
+        moderation={uiState.profile.moderation.view}>
         {uiState.profile.hasError ? (
           <ErrorScreen
             testID="profileErrorScreen"
@@ -169,7 +174,7 @@ export const ProfileScreen = withAuthRequired(
           onPress={onPressCompose}
           icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
         />
-      </View>
+      </ScreenHider>
     )
   }),
 )
diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx
index de64b2d67..4522d79ee 100644
--- a/src/view/screens/SearchMobile.tsx
+++ b/src/view/screens/SearchMobile.tsx
@@ -146,19 +146,14 @@ export const SearchScreen = withAuthRequired(
               scrollEventThrottle={100}>
               {query && autocompleteView.searchRes.length ? (
                 <>
-                  {autocompleteView.searchRes.map(
-                    ({did, handle, displayName, labels, avatar}, index) => (
-                      <ProfileCard
-                        key={did}
-                        testID={`searchAutoCompleteResult-${handle}`}
-                        handle={handle}
-                        displayName={displayName}
-                        labels={labels}
-                        avatar={avatar}
-                        noBorder={index === 0}
-                      />
-                    ),
-                  )}
+                  {autocompleteView.searchRes.map((profile, index) => (
+                    <ProfileCard
+                      key={profile.did}
+                      testID={`searchAutoCompleteResult-${profile.handle}`}
+                      profile={profile}
+                      noBorder={index === 0}
+                    />
+                  ))}
                 </>
               ) : query && !autocompleteView.searchRes.length ? (
                 <View>
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 995471944..5504e9415 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -85,14 +85,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
           {autocompleteView.searchRes.length ? (
             <>
               {autocompleteView.searchRes.map((item, i) => (
-                <ProfileCard
-                  key={item.did}
-                  handle={item.handle}
-                  displayName={item.displayName}
-                  avatar={item.avatar}
-                  labels={item.labels}
-                  noBorder={i === 0}
-                />
+                <ProfileCard key={item.did} profile={item} noBorder={i === 0} />
               ))}
             </>
           ) : (
diff --git a/yarn.lock b/yarn.lock
index a6f174a25..268d46fc5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -30,10 +30,10 @@
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
 
-"@atproto/api@0.2.9":
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.9.tgz#08e29da66d1a9001d9d3ce427548c1760d805e99"
-  integrity sha512-r00IqidX2YF3VUEa4MUO2Vxqp3+QhI1cSNcWgzT4LsANapzrwdDTM+rY2Ejp9na3F+unO4SWRW3o434cVmG5gw==
+"@atproto/api@0.2.10":
+  version "0.2.10"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.2.10.tgz#19c4d695f88ab4e45e4c9f2f4db5fad61590a3d2"
+  integrity sha512-97UBtvIXhsgNO7bXhHk0JwDNwyqTcL1N0JT2rnXjUeLKNf2hDvomFtI50Y4RFU942uUS5W5VtM+JJuZO5Ryw5w==
   dependencies:
     "@atproto/common-web" "*"
     "@atproto/uri" "*"