about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/labeling/const.ts50
-rw-r--r--src/lib/labeling/helpers.ts19
-rw-r--r--src/state/models/content/profile.ts3
-rw-r--r--src/state/models/feeds/notifications.ts315
-rw-r--r--src/state/models/me.ts2
-rw-r--r--src/state/models/ui/preferences.ts63
-rw-r--r--src/state/models/ui/shell.ts5
-rw-r--r--src/view/com/discover/SuggestedFollows.tsx1
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx185
-rw-r--r--src/view/com/modals/Modal.tsx9
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/notifications/Feed.tsx1
-rw-r--r--src/view/com/notifications/FeedItem.tsx23
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx1
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx88
-rw-r--r--src/view/com/post/Post.tsx217
-rw-r--r--src/view/com/posts/FeedItem.tsx263
-rw-r--r--src/view/com/profile/ProfileCard.tsx9
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx1
-rw-r--r--src/view/com/profile/ProfileFollows.tsx1
-rw-r--r--src/view/com/profile/ProfileHeader.tsx8
-rw-r--r--src/view/com/search/SearchResults.tsx1
-rw-r--r--src/view/com/util/LoadLatestBtn.tsx52
-rw-r--r--src/view/com/util/LoadLatestBtn.web.tsx14
-rw-r--r--src/view/com/util/PostMeta.tsx7
-rw-r--r--src/view/com/util/PostMuted.tsx50
-rw-r--r--src/view/com/util/UserAvatar.tsx47
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx109
-rw-r--r--src/view/com/util/moderation/PostHider.tsx105
-rw-r--r--src/view/com/util/moderation/ProfileHeaderLabels.tsx55
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx1
-rw-r--r--src/view/index.ts4
-rw-r--r--src/view/screens/Home.tsx2
-rw-r--r--src/view/screens/Notifications.tsx59
-rw-r--r--src/view/screens/Search.tsx1
-rw-r--r--src/view/screens/Settings.tsx19
-rw-r--r--src/view/shell/desktop/Search.tsx1
38 files changed, 1277 insertions, 518 deletions
diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts
new file mode 100644
index 000000000..8403fdf1a
--- /dev/null
+++ b/src/lib/labeling/const.ts
@@ -0,0 +1,50 @@
+import {LabelPreferencesModel} from 'state/models/ui/preferences'
+
+export interface LabelValGroup {
+  id: keyof LabelPreferencesModel | 'illegal' | 'unknown'
+  title: string
+  values: string[]
+}
+
+export const ILLEGAL_LABEL_GROUP: LabelValGroup = {
+  id: 'illegal',
+  title: 'Illegal Content',
+  values: ['csam', 'dmca-violation', 'nudity-nonconsentual'],
+}
+
+export const UNKNOWN_LABEL_GROUP: LabelValGroup = {
+  id: 'unknown',
+  title: 'Unknown Label',
+  values: [],
+}
+
+export const CONFIGURABLE_LABEL_GROUPS: Record<
+  keyof LabelPreferencesModel,
+  LabelValGroup
+> = {
+  nsfw: {
+    id: 'nsfw',
+    title: 'Sexual Content',
+    values: ['porn', 'nudity', 'sexual'],
+  },
+  gore: {
+    id: 'gore',
+    title: 'Violent / Bloody',
+    values: ['gore', 'self-harm', 'torture'],
+  },
+  hate: {
+    id: 'hate',
+    title: 'Political Hate-Groups',
+    values: ['icon-kkk', 'icon-nazi', 'icon-confederate'],
+  },
+  spam: {
+    id: 'spam',
+    title: 'Spam',
+    values: ['spam'],
+  },
+  impersonation: {
+    id: 'impersonation',
+    title: 'Impersonation',
+    values: ['impersonation'],
+  },
+}
diff --git a/src/lib/labeling/helpers.ts b/src/lib/labeling/helpers.ts
new file mode 100644
index 000000000..b2057ff18
--- /dev/null
+++ b/src/lib/labeling/helpers.ts
@@ -0,0 +1,19 @@
+import {
+  LabelValGroup,
+  CONFIGURABLE_LABEL_GROUPS,
+  ILLEGAL_LABEL_GROUP,
+  UNKNOWN_LABEL_GROUP,
+} from './const'
+
+export function getLabelValueGroup(labelVal: string): LabelValGroup {
+  let id: keyof typeof CONFIGURABLE_LABEL_GROUPS
+  for (id in CONFIGURABLE_LABEL_GROUPS) {
+    if (ILLEGAL_LABEL_GROUP.values.includes(labelVal)) {
+      return ILLEGAL_LABEL_GROUP
+    }
+    if (CONFIGURABLE_LABEL_GROUPS[id].values.includes(labelVal)) {
+      return CONFIGURABLE_LABEL_GROUPS[id]
+    }
+  }
+  return UNKNOWN_LABEL_GROUP
+}
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 8d9c71b39..45d928c92 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -1,6 +1,7 @@
 import {makeAutoObservable, runInAction} from 'mobx'
 import {PickedMedia} from 'lib/media/picker'
 import {
+  ComAtprotoLabelDefs,
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
   RichText,
@@ -41,6 +42,7 @@ export class ProfileModel {
   followersCount: number = 0
   followsCount: number = 0
   postsCount: number = 0
+  labels?: ComAtprotoLabelDefs.Label[] = undefined
   viewer = new ProfileViewerModel()
 
   // added data
@@ -210,6 +212,7 @@ export class ProfileModel {
     this.followersCount = res.data.followersCount || 0
     this.followsCount = res.data.followsCount || 0
     this.postsCount = res.data.postsCount || 0
+    this.labels = res.data.labels
     if (res.data.viewer) {
       Object.assign(this.viewer, res.data.viewer)
       this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 4daa3ca8d..12db9510d 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -6,6 +6,7 @@ import {
   AppBskyFeedRepost,
   AppBskyFeedLike,
   AppBskyGraphFollow,
+  ComAtprotoLabelDefs,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
@@ -20,6 +21,8 @@ const MS_2DAY = MS_1HR * 48
 
 let _idCounter = 0
 
+type CondFn = (notif: ListNotifications.Notification) => boolean
+
 export interface GroupedNotification extends ListNotifications.Notification {
   additional?: ListNotifications.Notification[]
 }
@@ -47,6 +50,7 @@ export class NotificationsFeedItemModel {
   record?: SupportedRecord
   isRead: boolean = false
   indexedAt: string = ''
+  labels?: ComAtprotoLabelDefs.Label[]
   additional?: NotificationsFeedItemModel[]
 
   // additional data
@@ -71,6 +75,7 @@ export class NotificationsFeedItemModel {
     this.record = this.toSupportedRecord(v.record)
     this.isRead = v.isRead
     this.indexedAt = v.indexedAt
+    this.labels = v.labels
     if (v.additional?.length) {
       this.additional = []
       for (const add of v.additional) {
@@ -83,6 +88,27 @@ export class NotificationsFeedItemModel {
     }
   }
 
+  get numUnreadInGroup(): number {
+    if (this.additional?.length) {
+      return (
+        this.additional.reduce(
+          (acc, notif) => acc + notif.numUnreadInGroup,
+          0,
+        ) + (this.isRead ? 0 : 1)
+      )
+    }
+    return this.isRead ? 0 : 1
+  }
+
+  markGroupRead() {
+    if (this.additional?.length) {
+      for (const notif of this.additional) {
+        notif.markGroupRead()
+      }
+    }
+    this.isRead = true
+  }
+
   get isLike() {
     return this.reason === 'like'
   }
@@ -192,7 +218,6 @@ export class NotificationsFeedModel {
   hasLoaded = false
   error = ''
   loadMoreError = ''
-  params: ListNotifications.QueryParams
   hasMore = true
   loadMoreCursor?: string
 
@@ -201,25 +226,21 @@ export class NotificationsFeedModel {
 
   // data
   notifications: NotificationsFeedItemModel[] = []
+  queuedNotifications: undefined | ListNotifications.Notification[] = undefined
   unreadCount = 0
 
   // this is used to help trigger push notifications
   mostRecentNotificationUri: string | undefined
 
-  constructor(
-    public rootStore: RootStoreModel,
-    params: ListNotifications.QueryParams,
-  ) {
+  constructor(public rootStore: RootStoreModel) {
     makeAutoObservable(
       this,
       {
         rootStore: false,
-        params: false,
         mostRecentNotificationUri: false,
       },
       {autoBind: true},
     )
-    this.params = params
   }
 
   get hasContent() {
@@ -234,6 +255,10 @@ export class NotificationsFeedModel {
     return this.hasLoaded && !this.hasContent
   }
 
+  get hasNewLatest() {
+    return this.queuedNotifications && this.queuedNotifications?.length > 0
+  }
+
   // public api
   // =
 
@@ -258,19 +283,17 @@ export class NotificationsFeedModel {
    * Load for first render
    */
   setup = bundleAsync(async (isRefreshing: boolean = false) => {
-    this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing})
-    if (isRefreshing) {
-      this.isRefreshing = true // set optimistically for UI
-    }
+    this.rootStore.log.debug('NotificationsModel:refresh', {isRefreshing})
     await this.lock.acquireAsync()
     try {
       this._xLoading(isRefreshing)
       try {
-        const params = Object.assign({}, this.params, {
-          limit: PAGE_SIZE,
+        const res = await this._fetchUntil(notif => notif.isRead, {
+          breakAt: 'page',
         })
-        const res = await this.rootStore.agent.listNotifications(params)
         await this._replaceAll(res)
+        this._setQueued(undefined)
+        this._countUnread()
         this._xIdle()
       } catch (e: any) {
         this._xIdle(e)
@@ -284,25 +307,80 @@ export class NotificationsFeedModel {
    * Reset and load
    */
   async refresh() {
+    this.isRefreshing = true // set optimistically for UI
     return this.setup(true)
   }
 
   /**
+   * Sync the next set of notifications to show
+   * returns true if the number changed
+   */
+  syncQueue = bundleAsync(async () => {
+    this.rootStore.log.debug('NotificationsModel:syncQueue')
+    await this.lock.acquireAsync()
+    try {
+      const res = await this._fetchUntil(
+        notif =>
+          this.notifications.length
+            ? isEq(notif, this.notifications[0])
+            : notif.isRead,
+        {breakAt: 'record'},
+      )
+      this._setQueued(res.data.notifications)
+      this._countUnread()
+    } catch (e) {
+      this.rootStore.log.error('NotificationsModel:syncQueue failed', {e})
+    } finally {
+      this.lock.release()
+    }
+  })
+
+  /**
+   *
+   */
+  processQueue = bundleAsync(async () => {
+    this.rootStore.log.debug('NotificationsModel:processQueue')
+    if (!this.queuedNotifications) {
+      return
+    }
+    this.isRefreshing = true
+    await this.lock.acquireAsync()
+    try {
+      runInAction(() => {
+        this.mostRecentNotificationUri = this.queuedNotifications?.[0].uri
+      })
+      const itemModels = await this._processNotifications(
+        this.queuedNotifications,
+      )
+      this._setQueued(undefined)
+      runInAction(() => {
+        this.notifications = itemModels.concat(this.notifications)
+      })
+    } catch (e) {
+      this.rootStore.log.error('NotificationsModel:processQueue failed', {e})
+    } finally {
+      runInAction(() => {
+        this.isRefreshing = false
+      })
+      this.lock.release()
+    }
+  })
+
+  /**
    * Load more posts to the end of the notifications
    */
   loadMore = bundleAsync(async () => {
     if (!this.hasMore) {
       return
     }
-    this.lock.acquireAsync()
+    await this.lock.acquireAsync()
     try {
       this._xLoading()
       try {
-        const params = Object.assign({}, this.params, {
+        const res = await this.rootStore.agent.listNotifications({
           limit: PAGE_SIZE,
           cursor: this.loadMoreCursor,
         })
-        const res = await this.rootStore.agent.listNotifications(params)
         await this._appendAll(res)
         this._xIdle()
       } catch (e: any) {
@@ -325,101 +403,37 @@ export class NotificationsFeedModel {
     return this.loadMore()
   }
 
-  /**
-   * Load more posts at the start of the notifications
-   */
-  loadLatest = bundleAsync(async () => {
-    if (this.notifications.length === 0 || this.unreadCount > PAGE_SIZE) {
-      return this.refresh()
-    }
-    this.lock.acquireAsync()
-    try {
-      this._xLoading()
-      try {
-        const res = await this.rootStore.agent.listNotifications({
-          limit: PAGE_SIZE,
-        })
-        await this._prependAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('NotificationsView: Failed to load latest', {
-          params: this.params,
-          e,
-        })
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Update content in-place
-   */
-  update = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.notifications.length) {
-        return
-      }
-      this._xLoading()
-      let numToFetch = this.notifications.length
-      let cursor
-      try {
-        do {
-          const res: ListNotifications.Response =
-            await this.rootStore.agent.listNotifications({
-              cursor,
-              limit: Math.min(numToFetch, 100),
-            })
-          if (res.data.notifications.length === 0) {
-            break // sanity check
-          }
-          this._updateAll(res)
-          numToFetch -= res.data.notifications.length
-          cursor = res.data.cursor
-        } while (cursor && numToFetch > 0)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('NotificationsView: Failed to update', {
-          params: this.params,
-          e,
-        })
+  // unread notification in-place
+  // =
+  async update() {
+    const promises = []
+    for (const item of this.notifications) {
+      if (item.additionalPost) {
+        promises.push(item.additionalPost.update())
       }
-    } finally {
-      this.lock.release()
     }
-  })
-
-  // unread notification apis
-  // =
-
-  /**
-   * Get the current number of unread notifications
-   * returns true if the number changed
-   */
-  loadUnreadCount = bundleAsync(async () => {
-    const old = this.unreadCount
-    const res = await this.rootStore.agent.countUnreadNotifications()
-    runInAction(() => {
-      this.unreadCount = res.data.count
+    await Promise.all(promises).catch(e => {
+      this.rootStore.log.error(
+        'Uncaught failure during notifications update()',
+        e,
+      )
     })
-    this.rootStore.emitUnreadNotifications(this.unreadCount)
-    return this.unreadCount !== old
-  })
+  }
 
   /**
    * Update read/unread state
    */
-  async markAllRead() {
+  async markAllUnqueuedRead() {
     try {
-      this.unreadCount = 0
-      this.rootStore.emitUnreadNotifications(0)
       for (const notif of this.notifications) {
-        notif.isRead = true
+        notif.markGroupRead()
+      }
+      this._countUnread()
+      if (this.notifications[0]) {
+        await this.rootStore.agent.updateSeenNotifications(
+          this.notifications[0].indexedAt,
+        )
       }
-      await this.rootStore.agent.updateSeenNotifications()
     } catch (e: any) {
       this.rootStore.log.warn('Failed to update notifications read state', e)
     }
@@ -472,6 +486,40 @@ export class NotificationsFeedModel {
   // helper functions
   // =
 
+  async _fetchUntil(
+    condFn: CondFn,
+    {breakAt}: {breakAt: 'page' | 'record'},
+  ): Promise<ListNotifications.Response> {
+    const accRes: ListNotifications.Response = {
+      success: true,
+      headers: {},
+      data: {cursor: undefined, notifications: []},
+    }
+    for (let i = 0; i <= 10; i++) {
+      const res = await this.rootStore.agent.listNotifications({
+        limit: PAGE_SIZE,
+        cursor: accRes.data.cursor,
+      })
+      accRes.data.cursor = res.data.cursor
+
+      let pageIsDone = false
+      for (const notif of res.data.notifications) {
+        if (condFn(notif)) {
+          if (breakAt === 'record') {
+            return accRes
+          } else {
+            pageIsDone = true
+          }
+        }
+        accRes.data.notifications.push(notif)
+      }
+      if (pageIsDone || res.data.notifications.length < PAGE_SIZE) {
+        return accRes
+      }
+    }
+    return accRes
+  }
+
   async _replaceAll(res: ListNotifications.Response) {
     if (res.data.notifications[0]) {
       this.mostRecentNotificationUri = res.data.notifications[0].uri
@@ -482,25 +530,7 @@ export class NotificationsFeedModel {
   async _appendAll(res: ListNotifications.Response, replace = false) {
     this.loadMoreCursor = res.data.cursor
     this.hasMore = !!this.loadMoreCursor
-    const promises = []
-    const itemModels: NotificationsFeedItemModel[] = []
-    for (const item of groupNotifications(res.data.notifications)) {
-      const itemModel = new NotificationsFeedItemModel(
-        this.rootStore,
-        `item-${_idCounter++}`,
-        item,
-      )
-      if (itemModel.needsAdditionalData) {
-        promises.push(itemModel.fetchAdditionalData())
-      }
-      itemModels.push(itemModel)
-    }
-    await Promise.all(promises).catch(e => {
-      this.rootStore.log.error(
-        'Uncaught failure during notifications-view _appendAll()',
-        e,
-      )
-    })
+    const itemModels = await this._processNotifications(res.data.notifications)
     runInAction(() => {
       if (replace) {
         this.notifications = itemModels
@@ -510,16 +540,18 @@ export class NotificationsFeedModel {
     })
   }
 
-  async _prependAll(res: ListNotifications.Response) {
+  async _processNotifications(
+    items: ListNotifications.Notification[],
+  ): Promise<NotificationsFeedItemModel[]> {
     const promises = []
     const itemModels: NotificationsFeedItemModel[] = []
-    const dedupedNotifs = res.data.notifications.filter(
-      n1 =>
-        !this.notifications.find(
-          n2 => isEq(n1, n2) || n2.additional?.find(n3 => isEq(n1, n3)),
-        ),
-    )
-    for (const item of groupNotifications(dedupedNotifs)) {
+    items = items.filter(item => {
+      return (
+        this.rootStore.preferences.getLabelPreference(item.labels).pref !==
+        'hide'
+      )
+    })
+    for (const item of groupNotifications(items)) {
       const itemModel = new NotificationsFeedItemModel(
         this.rootStore,
         `item-${_idCounter++}`,
@@ -532,22 +564,27 @@ export class NotificationsFeedModel {
     }
     await Promise.all(promises).catch(e => {
       this.rootStore.log.error(
-        'Uncaught failure during notifications-view _prependAll()',
+        'Uncaught failure during notifications _processNotifications()',
         e,
       )
     })
-    runInAction(() => {
-      this.notifications = itemModels.concat(this.notifications)
-    })
+    return itemModels
   }
 
-  _updateAll(res: ListNotifications.Response) {
-    for (const item of res.data.notifications) {
-      const existingItem = this.notifications.find(item2 => isEq(item, item2))
-      if (existingItem) {
-        existingItem.copy(item, true)
-      }
+  _setQueued(queued: undefined | ListNotifications.Notification[]) {
+    this.queuedNotifications = queued
+  }
+
+  _countUnread() {
+    let unread = 0
+    for (const notif of this.notifications) {
+      unread += notif.numUnreadInGroup
+    }
+    if (this.queuedNotifications) {
+      unread += this.queuedNotifications.length
     }
+    this.unreadCount = unread
+    this.rootStore.emitUnreadNotifications(unread)
   }
 }
 
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index a0591aeca..3774e1e56 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -119,7 +119,7 @@ export class MeModel {
       await this.fetchProfile()
       await this.fetchInviteCodes()
     }
-    await this.notifications.loadUnreadCount()
+    await this.notifications.syncQueue()
   }
 
   async fetchProfile() {
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index bffb2d89b..5ab5d13fc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,11 +1,33 @@
 import {makeAutoObservable} from 'mobx'
 import {getLocales} from 'expo-localization'
 import {isObj, hasProp} from 'lib/type-guards'
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {getLabelValueGroup} from 'lib/labeling/helpers'
+import {
+  LabelValGroup,
+  UNKNOWN_LABEL_GROUP,
+  ILLEGAL_LABEL_GROUP,
+} from 'lib/labeling/const'
 
 const deviceLocales = getLocales()
 
+export type LabelPreference = 'show' | 'warn' | 'hide'
+
+export class LabelPreferencesModel {
+  nsfw: LabelPreference = 'warn'
+  gore: LabelPreference = 'hide'
+  hate: LabelPreference = 'hide'
+  spam: LabelPreference = 'hide'
+  impersonation: LabelPreference = 'warn'
+
+  constructor() {
+    makeAutoObservable(this, {}, {autoBind: true})
+  }
+}
+
 export class PreferencesModel {
   _contentLanguages: string[] | undefined
+  contentLabels = new LabelPreferencesModel()
 
   constructor() {
     makeAutoObservable(this, {}, {autoBind: true})
@@ -22,6 +44,7 @@ export class PreferencesModel {
   serialize() {
     return {
       contentLanguages: this._contentLanguages,
+      contentLabels: this.contentLabels,
     }
   }
 
@@ -34,6 +57,46 @@ export class PreferencesModel {
       ) {
         this._contentLanguages = v.contentLanguages
       }
+      if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') {
+        Object.assign(this.contentLabels, v.contentLabels)
+      }
+    }
+  }
+
+  setContentLabelPref(
+    key: keyof LabelPreferencesModel,
+    value: LabelPreference,
+  ) {
+    this.contentLabels[key] = value
+  }
+
+  getLabelPreference(labels: ComAtprotoLabelDefs.Label[] | undefined): {
+    pref: LabelPreference
+    desc: LabelValGroup
+  } {
+    let res: {pref: LabelPreference; desc: LabelValGroup} = {
+      pref: 'show',
+      desc: UNKNOWN_LABEL_GROUP,
+    }
+    if (!labels?.length) {
+      return res
+    }
+    for (const label of labels) {
+      const group = getLabelValueGroup(label.val)
+      if (group.id === 'illegal') {
+        return {pref: 'hide', desc: ILLEGAL_LABEL_GROUP}
+      } else if (group.id === 'unknown') {
+        continue
+      }
+      let pref = this.contentLabels[group.id]
+      if (pref === 'hide') {
+        res.pref = 'hide'
+        res.desc = group
+      } else if (pref === 'warn' && res.pref === 'show') {
+        res.pref = 'warn'
+        res.desc = group
+      }
     }
+    return res
   }
 }
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 917e7a09f..dd5c899b3 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -65,6 +65,10 @@ export interface InviteCodesModal {
   name: 'invite-codes'
 }
 
+export interface ContentFilteringSettingsModal {
+  name: 'content-filtering-settings'
+}
+
 export type Modal =
   | ConfirmModal
   | EditProfileModal
@@ -77,6 +81,7 @@ export type Modal =
   | ChangeHandleModal
   | WaitlistModal
   | InviteCodesModal
+  | ContentFilteringSettingsModal
 
 interface LightboxModel {}
 
diff --git a/src/view/com/discover/SuggestedFollows.tsx b/src/view/com/discover/SuggestedFollows.tsx
index e4ada5204..ae5605c5c 100644
--- a/src/view/com/discover/SuggestedFollows.tsx
+++ b/src/view/com/discover/SuggestedFollows.tsx
@@ -31,6 +31,7 @@ export const SuggestedFollows = ({
             handle={item.handle}
             displayName={item.displayName}
             avatar={item.avatar}
+            labels={item.labels}
             noBg
             noBorder
             description={
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
new file mode 100644
index 000000000..2e015e404
--- /dev/null
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -0,0 +1,185 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {LabelPreference} from 'state/models/ui/preferences'
+import {s, colors, gradients} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const'
+
+export const snapPoints = [500]
+
+export function Component({}: {}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const onPressDone = React.useCallback(() => {
+    store.shell.closeModal()
+  }, [store])
+
+  return (
+    <View testID="reportPostModal" style={[pal.view, styles.container]}>
+      <Text style={[pal.text, styles.title]}>Content Filtering</Text>
+      <ContentLabelPref group="nsfw" />
+      <ContentLabelPref group="gore" />
+      <ContentLabelPref group="hate" />
+      <ContentLabelPref group="spam" />
+      <ContentLabelPref group="impersonation" />
+      <View style={s.flex1} />
+      <TouchableOpacity testID="sendReportBtn" onPress={onPressDone}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+        </LinearGradient>
+      </TouchableOpacity>
+    </View>
+  )
+}
+
+const ContentLabelPref = observer(
+  ({group}: {group: keyof typeof CONFIGURABLE_LABEL_GROUPS}) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    return (
+      <View style={[styles.contentLabelPref, pal.border]}>
+        <Text type="md-medium" style={[pal.text]}>
+          {CONFIGURABLE_LABEL_GROUPS[group].title}
+        </Text>
+        <SelectGroup
+          current={store.preferences.contentLabels[group]}
+          onChange={v => store.preferences.setContentLabelPref(group, v)}
+        />
+      </View>
+    )
+  },
+)
+
+function SelectGroup({
+  current,
+  onChange,
+}: {
+  current: LabelPreference
+  onChange: (v: LabelPreference) => void
+}) {
+  return (
+    <View style={styles.selectableBtns}>
+      <SelectableBtn
+        current={current}
+        value="hide"
+        label="Hide"
+        left
+        onChange={onChange}
+      />
+      <SelectableBtn
+        current={current}
+        value="warn"
+        label="Warn"
+        onChange={onChange}
+      />
+      <SelectableBtn
+        current={current}
+        value="show"
+        label="Show"
+        right
+        onChange={onChange}
+      />
+    </View>
+  )
+}
+
+function SelectableBtn({
+  current,
+  value,
+  label,
+  left,
+  right,
+  onChange,
+}: {
+  current: string
+  value: LabelPreference
+  label: string
+  left?: boolean
+  right?: boolean
+  onChange: (v: LabelPreference) => void
+}) {
+  const pal = usePalette('default')
+  const palPrimary = usePalette('inverted')
+  return (
+    <TouchableOpacity
+      style={[
+        styles.selectableBtn,
+        left && styles.selectableBtnLeft,
+        right && styles.selectableBtnRight,
+        pal.border,
+        current === value ? palPrimary.view : pal.view,
+      ]}
+      onPress={() => onChange(value)}>
+      <Text style={current === value ? palPrimary.text : pal.text}>
+        {label}
+      </Text>
+    </TouchableOpacity>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingHorizontal: 10,
+    paddingBottom: 40,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  description: {
+    paddingHorizontal: 2,
+    marginBottom: 10,
+  },
+
+  contentLabelPref: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    paddingTop: 10,
+    paddingLeft: 4,
+    marginBottom: 10,
+    borderTopWidth: 1,
+  },
+
+  selectableBtns: {
+    flexDirection: 'row',
+  },
+  selectableBtn: {
+    flexDirection: 'row',
+    justifyContent: 'center',
+    borderWidth: 1,
+    borderLeftWidth: 0,
+    paddingHorizontal: 10,
+    paddingVertical: 10,
+  },
+  selectableBtnLeft: {
+    borderTopLeftRadius: 8,
+    borderBottomLeftRadius: 8,
+    borderLeftWidth: 1,
+  },
+  selectableBtnRight: {
+    borderTopRightRadius: 8,
+    borderBottomRightRadius: 8,
+  },
+
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index b1c7d4738..3f10ec836 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,9 +1,10 @@
 import React, {useRef, useEffect} from 'react'
-import {View} from 'react-native'
+import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
+import {usePalette} from 'lib/hooks/usePalette'
 
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
@@ -15,8 +16,7 @@ import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
-import {usePalette} from 'lib/hooks/usePalette'
-import {StyleSheet} from 'react-native'
+import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -77,6 +77,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'invite-codes') {
     snapPoints = InviteCodesModal.snapPoints
     element = <InviteCodesModal.Component />
+  } else if (activeModal?.name === 'content-filtering-settings') {
+    snapPoints = ContentFilteringSettingsModal.snapPoints
+    element = <ContentFilteringSettingsModal.Component />
   } else {
     return <View />
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index e6d54926b..6f026e174 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -17,6 +17,7 @@ import * as CropImageModal from './crop-image/CropImage.web'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
+import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
@@ -75,6 +76,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <WaitlistModal.Component />
   } else if (modal.name === 'invite-codes') {
     element = <InviteCodesModal.Component />
+  } else if (modal.name === 'content-filtering-settings') {
+    element = <ContentFilteringSettingsModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 2196b3469..23a3166db 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -45,7 +45,6 @@ export const Feed = observer(function Feed({
   const onRefresh = React.useCallback(async () => {
     try {
       await view.refresh()
-      await view.markAllRead()
     } catch (err) {
       view.rootStore.log.error('Failed to refresh notifications feed', err)
     }
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index e77eae17e..22a354da0 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} from '@atproto/api'
+import {AtUri, ComAtprotoLabelDefs} from '@atproto/api'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -38,6 +38,7 @@ interface Author {
   handle: string
   displayName?: string
   avatar?: string
+  labels?: ComAtprotoLabelDefs.Label[]
 }
 
 export const FeedItem = observer(function FeedItem({
@@ -129,6 +130,7 @@ export const FeedItem = observer(function FeedItem({
       handle: item.author.handle,
       displayName: item.author.displayName,
       avatar: item.author.avatar,
+      labels: item.author.labels,
     },
   ]
   if (item.additional?.length) {
@@ -138,6 +140,7 @@ export const FeedItem = observer(function FeedItem({
         handle: item2.author.handle,
         displayName: item2.author.displayName,
         avatar: item2.author.avatar,
+        labels: item.author.labels,
       })),
     )
   }
@@ -255,7 +258,11 @@ function CondensedAuthorsList({
           href={authors[0].href}
           title={`@${authors[0].handle}`}
           asAnchor>
-          <UserAvatar size={35} avatar={authors[0].avatar} />
+          <UserAvatar
+            size={35}
+            avatar={authors[0].avatar}
+            hasWarning={!!authors[0].labels?.length}
+          />
         </Link>
       </View>
     )
@@ -264,7 +271,11 @@ function CondensedAuthorsList({
     <View style={styles.avis}>
       {authors.slice(0, MAX_AUTHORS).map(author => (
         <View key={author.href} style={s.mr5}>
-          <UserAvatar size={35} avatar={author.avatar} />
+          <UserAvatar
+            size={35}
+            avatar={author.avatar}
+            hasWarning={!!author.labels?.length}
+          />
         </View>
       ))}
       {authors.length > MAX_AUTHORS ? (
@@ -317,7 +328,11 @@ function ExpandedAuthorsList({
           style={styles.expandedAuthor}
           asAnchor>
           <View style={styles.expandedAuthorAvi}>
-            <UserAvatar size={35} avatar={author.avatar} />
+            <UserAvatar
+              size={35}
+              avatar={author.avatar}
+              hasWarning={!!author.labels?.length}
+            />
           </View>
           <View style={s.flex1}>
             <Text
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 1b65c04fc..3ab9a279b 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -53,6 +53,7 @@ export const PostLikedBy = observer(function ({uri}: {uri: string}) {
       handle={item.actor.handle}
       displayName={item.actor.displayName}
       avatar={item.actor.avatar}
+      labels={item.actor.labels}
       isFollowedBy={!!item.actor.viewer?.followedBy}
     />
   )
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 30f8fd445..9874460e9 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -64,6 +64,7 @@ export const PostRepostedBy = observer(function PostRepostedBy({
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
+      labels={item.labels}
       isFollowedBy={!!item.viewer?.followedBy}
     />
   )
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 3d3647f60..6e8758f7e 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -22,7 +22,8 @@ import {useStores} from 'state/index'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/PostCtrls'
-import {PostMutedWrapper} from '../util/PostMuted'
+import {PostHider} from '../util/moderation/PostHider'
+import {ContentHider} from '../util/moderation/ContentHider'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 
@@ -137,7 +138,11 @@ export const PostThreadItem = observer(function PostThreadItem({
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
             <Link href={authorHref} title={authorTitle} asAnchor>
-              <UserAvatar size={52} avatar={item.post.author.avatar} />
+              <UserAvatar
+                size={52}
+                avatar={item.post.author.avatar}
+                hasWarning={!!item.post.author.labels?.length}
+              />
             </Link>
           </View>
           <View style={styles.layoutContent}>
@@ -193,17 +198,24 @@ export const PostThreadItem = observer(function PostThreadItem({
           </View>
         </View>
         <View style={[s.pl10, s.pr10, s.pb10]}>
-          {item.richText?.text ? (
-            <View
-              style={[styles.postTextContainer, styles.postTextLargeContainer]}>
-              <RichText
-                type="post-text-lg"
-                richText={item.richText}
-                lineHeight={1.3}
-              />
-            </View>
-          ) : undefined}
-          <PostEmbeds embed={item.post.embed} style={s.mb10} />
+          <ContentHider
+            isMuted={item.post.author.viewer?.muted === true}
+            labels={item.post.labels}>
+            {item.richText?.text ? (
+              <View
+                style={[
+                  styles.postTextContainer,
+                  styles.postTextLargeContainer,
+                ]}>
+                <RichText
+                  type="post-text-lg"
+                  richText={item.richText}
+                  lineHeight={1.3}
+                />
+              </View>
+            ) : undefined}
+            <PostEmbeds embed={item.post.embed} style={s.mb10} />
+          </ContentHider>
           {item._isHighlightedPost && hasEngagement ? (
             <View style={[styles.expandedInfo, pal.border]}>
               {item.post.repostCount ? (
@@ -270,13 +282,13 @@ export const PostThreadItem = observer(function PostThreadItem({
     )
   } else {
     return (
-      <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
-        <Link
+      <>
+        <PostHider
           testID={`postThreadItem-by-${item.post.author.handle}`}
-          style={[styles.outer, {borderTopColor: pal.colors.border}, pal.view]}
           href={itemHref}
-          title={itemTitle}
-          noFeedback>
+          style={[styles.outer, {borderColor: pal.colors.border}, pal.view]}
+          isMuted={item.post.author.viewer?.muted === true}
+          labels={item.post.labels}>
           {item._showParentReplyLine && (
             <View
               style={[
@@ -296,28 +308,37 @@ export const PostThreadItem = observer(function PostThreadItem({
           <View style={styles.layout}>
             <View style={styles.layoutAvi}>
               <Link href={authorHref} title={authorTitle} asAnchor>
-                <UserAvatar size={52} avatar={item.post.author.avatar} />
+                <UserAvatar
+                  size={52}
+                  avatar={item.post.author.avatar}
+                  hasWarning={!!item.post.author.labels?.length}
+                />
               </Link>
             </View>
             <View style={styles.layoutContent}>
               <PostMeta
                 authorHandle={item.post.author.handle}
                 authorDisplayName={item.post.author.displayName}
+                authorHasWarning={!!item.post.author.labels?.length}
                 timestamp={item.post.indexedAt}
                 postHref={itemHref}
                 did={item.post.author.did}
               />
-              {item.richText?.text ? (
-                <View style={styles.postTextContainer}>
-                  <RichText
-                    type="post-text"
-                    richText={item.richText}
-                    style={pal.text}
-                    lineHeight={1.3}
-                  />
-                </View>
-              ) : undefined}
-              <PostEmbeds embed={item.post.embed} style={s.mb10} />
+              <ContentHider
+                labels={item.post.labels}
+                containerStyle={styles.contentHider}>
+                {item.richText?.text ? (
+                  <View style={styles.postTextContainer}>
+                    <RichText
+                      type="post-text"
+                      richText={item.richText}
+                      style={pal.text}
+                      lineHeight={1.3}
+                    />
+                  </View>
+                ) : undefined}
+                <PostEmbeds embed={item.post.embed} style={s.mb10} />
+              </ContentHider>
               <PostCtrls
                 itemUri={itemUri}
                 itemCid={itemCid}
@@ -345,7 +366,7 @@ export const PostThreadItem = observer(function PostThreadItem({
               />
             </View>
           </View>
-        </Link>
+        </PostHider>
         {item._hasMore ? (
           <Link
             style={[
@@ -364,7 +385,7 @@ export const PostThreadItem = observer(function PostThreadItem({
             />
           </Link>
         ) : undefined}
-      </PostMutedWrapper>
+      </>
     )
   }
 })
@@ -433,6 +454,9 @@ const styles = StyleSheet.create({
     paddingHorizontal: 0,
     paddingBottom: 10,
   },
+  contentHider: {
+    marginTop: 4,
+  },
   expandedInfo: {
     flexDirection: 'row',
     padding: 10,
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index e8e741269..60d46f5cc 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -7,17 +7,22 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
+import {AppBskyFeedPost as FeedPost} from '@atproto/api'
 import {observer} from 'mobx-react-lite'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {AtUri} from '@atproto/api'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {PostThreadModel} from 'state/models/content/post-thread'
+import {
+  PostThreadModel,
+  PostThreadItemModel,
+} from 'state/models/content/post-thread'
 import {Link} from '../util/Link'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/PostCtrls'
-import {PostMutedWrapper} from '../util/PostMuted'
+import {PostHider} from '../util/moderation/PostHider'
+import {ContentHider} from '../util/moderation/ContentHider'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
@@ -61,7 +66,11 @@ export const Post = observer(function Post({
 
   // loading
   // =
-  if (!view || view.isLoading || view.params.uri !== uri) {
+  if (
+    !view ||
+    (!view.hasContent && view.isLoading) ||
+    view.params.uri !== uri
+  ) {
     return (
       <View style={pal.view}>
         <ActivityIndicator />
@@ -84,85 +93,122 @@ export const Post = observer(function Post({
 
   // loaded
   // =
-  const item = view.thread
-  const record = view.thread.postRecord
 
-  const itemUri = item.post.uri
-  const itemCid = item.post.cid
-  const itemUrip = new AtUri(item.post.uri)
-  const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
-  const itemTitle = `Post by ${item.post.author.handle}`
-  const authorHref = `/profile/${item.post.author.handle}`
-  const authorTitle = item.post.author.handle
-  let replyAuthorDid = ''
-  if (record.reply) {
-    const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
-    replyAuthorDid = urip.hostname
-  }
-  const onPressReply = () => {
-    store.shell.openComposer({
-      replyTo: {
-        uri: item.post.uri,
-        cid: item.post.cid,
-        text: record.text as string,
-        author: {
-          handle: item.post.author.handle,
-          displayName: item.post.author.displayName,
-          avatar: item.post.author.avatar,
+  return (
+    <PostLoaded
+      item={view.thread}
+      record={view.thread.postRecord}
+      setDeleted={setDeleted}
+      showReplyLine={showReplyLine}
+      style={style}
+    />
+  )
+})
+
+const PostLoaded = observer(
+  ({
+    item,
+    record,
+    setDeleted,
+    showReplyLine,
+    style,
+  }: {
+    item: PostThreadItemModel
+    record: FeedPost.Record
+    setDeleted: (v: boolean) => void
+    showReplyLine?: boolean
+    style?: StyleProp<ViewStyle>
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+
+    const itemUri = item.post.uri
+    const itemCid = item.post.cid
+    const itemUrip = new AtUri(item.post.uri)
+    const itemHref = `/profile/${item.post.author.handle}/post/${itemUrip.rkey}`
+    const itemTitle = `Post by ${item.post.author.handle}`
+    const authorHref = `/profile/${item.post.author.handle}`
+    const authorTitle = item.post.author.handle
+    let replyAuthorDid = ''
+    if (record.reply) {
+      const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
+      replyAuthorDid = urip.hostname
+    }
+    const onPressReply = React.useCallback(() => {
+      store.shell.openComposer({
+        replyTo: {
+          uri: item.post.uri,
+          cid: item.post.cid,
+          text: record.text as string,
+          author: {
+            handle: item.post.author.handle,
+            displayName: item.post.author.displayName,
+            avatar: item.post.author.avatar,
+          },
         },
-      },
-    })
-  }
-  const onPressToggleRepost = () => {
-    return item
-      .toggleRepost()
-      .catch(e => store.log.error('Failed to toggle repost', e))
-  }
-  const onPressToggleLike = () => {
-    return item
-      .toggleLike()
-      .catch(e => store.log.error('Failed to toggle like', e))
-  }
-  const onCopyPostText = () => {
-    Clipboard.setString(record.text)
-    Toast.show('Copied to clipboard')
-  }
-  const onOpenTranslate = () => {
-    Linking.openURL(
-      encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`),
-    )
-  }
-  const onDeletePost = () => {
-    item.delete().then(
-      () => {
-        setDeleted(true)
-        Toast.show('Post deleted')
-      },
-      e => {
-        store.log.error('Failed to delete post', e)
-        Toast.show('Failed to delete post, please try again')
-      },
-    )
-  }
+      })
+    }, [store, item, record])
 
-  return (
-    <PostMutedWrapper isMuted={item.post.author.viewer?.muted === true}>
-      <Link
-        style={[styles.outer, pal.view, pal.border, style]}
+    const onPressToggleRepost = React.useCallback(() => {
+      return item
+        .toggleRepost()
+        .catch(e => store.log.error('Failed to toggle repost', e))
+    }, [item, store])
+
+    const onPressToggleLike = React.useCallback(() => {
+      return item
+        .toggleLike()
+        .catch(e => store.log.error('Failed to toggle like', e))
+    }, [item, store])
+
+    const onCopyPostText = React.useCallback(() => {
+      Clipboard.setString(record.text)
+      Toast.show('Copied to clipboard')
+    }, [record])
+
+    const onOpenTranslate = React.useCallback(() => {
+      Linking.openURL(
+        encodeURI(
+          `https://translate.google.com/#auto|en|${record?.text || ''}`,
+        ),
+      )
+    }, [record])
+
+    const onDeletePost = React.useCallback(() => {
+      item.delete().then(
+        () => {
+          setDeleted(true)
+          Toast.show('Post deleted')
+        },
+        e => {
+          store.log.error('Failed to delete post', e)
+          Toast.show('Failed to delete post, please try again')
+        },
+      )
+    }, [item, setDeleted, store])
+
+    return (
+      <PostHider
         href={itemHref}
-        title={itemTitle}
-        noFeedback>
+        style={[styles.outer, pal.view, pal.border, style]}
+        isMuted={item.post.author.viewer?.muted === true}
+        labels={item.post.labels}>
         {showReplyLine && <View style={styles.replyLine} />}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
             <Link href={authorHref} title={authorTitle} asAnchor>
-              <UserAvatar size={52} avatar={item.post.author.avatar} />
+              <UserAvatar
+                size={52}
+                avatar={item.post.author.avatar}
+                hasWarning={!!item.post.author.labels?.length}
+              />
             </Link>
           </View>
           <View style={styles.layoutContent}>
             <PostMeta
               authorHandle={item.post.author.handle}
               authorDisplayName={item.post.author.displayName}
+              authorHasWarning={!!item.post.author.labels?.length}
               timestamp={item.post.indexedAt}
               postHref={itemHref}
               did={item.post.author.did}
@@ -185,16 +231,20 @@ export const Post = observer(function Post({
                 />
               </View>
             )}
-            {item.richText?.text ? (
-              <View style={styles.postTextContainer}>
-                <RichText
-                  type="post-text"
-                  richText={item.richText}
-                  lineHeight={1.3}
-                />
-              </View>
-            ) : undefined}
-            <PostEmbeds embed={item.post.embed} style={s.mb10} />
+            <ContentHider
+              labels={item.post.labels}
+              containerStyle={styles.contentHider}>
+              {item.richText?.text ? (
+                <View style={styles.postTextContainer}>
+                  <RichText
+                    type="post-text"
+                    richText={item.richText}
+                    lineHeight={1.3}
+                  />
+                </View>
+              ) : undefined}
+              <PostEmbeds embed={item.post.embed} style={s.mb10} />
+            </ContentHider>
             <PostCtrls
               itemUri={itemUri}
               itemCid={itemCid}
@@ -222,10 +272,10 @@ export const Post = observer(function Post({
             />
           </View>
         </View>
-      </Link>
-    </PostMutedWrapper>
-  )
-})
+      </PostHider>
+    )
+  },
+)
 
 const styles = StyleSheet.create({
   outer: {
@@ -257,4 +307,7 @@ const styles = StyleSheet.create({
     borderLeftWidth: 2,
     borderLeftColor: colors.gray2,
   },
+  contentHider: {
+    marginTop: 4,
+  },
 })
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 569d11257..c2baa4d4d 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -14,7 +14,8 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostMutedWrapper} from '../util/PostMuted'
+import {PostHider} from '../util/moderation/PostHider'
+import {ContentHider} from '../util/moderation/ContentHider'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
 import {UserAvatar} from '../util/UserAvatar'
@@ -59,7 +60,7 @@ export const FeedItem = observer(function ({
     return urip.hostname
   }, [record?.reply])
 
-  const onPressReply = () => {
+  const onPressReply = React.useCallback(() => {
     track('FeedItem:PostReply')
     store.shell.openComposer({
       replyTo: {
@@ -73,29 +74,34 @@ export const FeedItem = observer(function ({
         },
       },
     })
-  }
-  const onPressToggleRepost = () => {
+  }, [item, track, record, store])
+
+  const onPressToggleRepost = React.useCallback(() => {
     track('FeedItem:PostRepost')
     return item
       .toggleRepost()
       .catch(e => store.log.error('Failed to toggle repost', e))
-  }
-  const onPressToggleLike = () => {
+  }, [track, item, store])
+
+  const onPressToggleLike = React.useCallback(() => {
     track('FeedItem:PostLike')
     return item
       .toggleLike()
       .catch(e => store.log.error('Failed to toggle like', e))
-  }
-  const onCopyPostText = () => {
+  }, [track, item, store])
+
+  const onCopyPostText = React.useCallback(() => {
     Clipboard.setString(record?.text || '')
     Toast.show('Copied to clipboard')
-  }
+  }, [record])
+
   const onOpenTranslate = React.useCallback(() => {
     Linking.openURL(
       encodeURI(`https://translate.google.com/#auto|en|${record?.text || ''}`),
     )
   }, [record])
-  const onDeletePost = () => {
+
+  const onDeletePost = React.useCallback(() => {
     track('FeedItem:PostDelete')
     item.delete().then(
       () => {
@@ -107,7 +113,7 @@ export const FeedItem = observer(function ({
         Toast.show('Failed to delete post, please try again')
       },
     )
-  }
+  }, [track, item, setDeleted, store])
 
   if (!record || deleted) {
     return <View />
@@ -127,97 +133,103 @@ export const FeedItem = observer(function ({
   ]
 
   return (
-    <PostMutedWrapper isMuted={isMuted}>
-      <Link
-        testID={`feedItem-by-${item.post.author.handle}`}
-        style={outerStyles}
-        href={itemHref}
-        title={itemTitle}
-        noFeedback>
-        {isThreadChild && (
-          <View
-            style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
-          />
-        )}
-        {isThreadParent && (
-          <View
+    <PostHider
+      testID={`feedItem-by-${item.post.author.handle}`}
+      style={outerStyles}
+      href={itemHref}
+      isMuted={isMuted}
+      labels={item.post.labels}>
+      {isThreadChild && (
+        <View
+          style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
+        />
+      )}
+      {isThreadParent && (
+        <View
+          style={[
+            styles.bottomReplyLine,
+            {borderColor: pal.colors.replyLine},
+            isNoTop ? styles.bottomReplyLineNoTop : undefined,
+          ]}
+        />
+      )}
+      {item.reasonRepost && (
+        <Link
+          style={styles.includeReason}
+          href={`/profile/${item.reasonRepost.by.handle}`}
+          title={sanitizeDisplayName(
+            item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
+          )}>
+          <FontAwesomeIcon
+            icon="retweet"
             style={[
-              styles.bottomReplyLine,
-              {borderColor: pal.colors.replyLine},
-              isNoTop ? styles.bottomReplyLineNoTop : undefined,
+              styles.includeReasonIcon,
+              {color: pal.colors.textLight} as FontAwesomeIconStyle,
             ]}
           />
-        )}
-        {item.reasonRepost && (
-          <Link
-            style={styles.includeReason}
-            href={`/profile/${item.reasonRepost.by.handle}`}
-            title={sanitizeDisplayName(
-              item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
-            )}>
-            <FontAwesomeIcon
-              icon="retweet"
-              style={[
-                styles.includeReasonIcon,
-                {color: pal.colors.textLight} as FontAwesomeIconStyle,
-              ]}
-            />
-            <Text
+          <Text
+            type="sm-bold"
+            style={pal.textLight}
+            lineHeight={1.2}
+            numberOfLines={1}>
+            Reposted by{' '}
+            <DesktopWebTextLink
               type="sm-bold"
               style={pal.textLight}
               lineHeight={1.2}
-              numberOfLines={1}>
-              Reposted by{' '}
-              <DesktopWebTextLink
-                type="sm-bold"
-                style={pal.textLight}
-                lineHeight={1.2}
-                numberOfLines={1}
-                text={sanitizeDisplayName(
-                  item.reasonRepost.by.displayName ||
-                    item.reasonRepost.by.handle,
-                )}
-                href={`/profile/${item.reasonRepost.by.handle}`}
-              />
-            </Text>
-          </Link>
-        )}
-        <View style={styles.layout}>
-          <View style={styles.layoutAvi}>
-            <Link href={authorHref} title={item.post.author.handle} asAnchor>
-              <UserAvatar size={52} avatar={item.post.author.avatar} />
-            </Link>
-          </View>
-          <View style={styles.layoutContent}>
-            <PostMeta
-              authorHandle={item.post.author.handle}
-              authorDisplayName={item.post.author.displayName}
-              timestamp={item.post.indexedAt}
-              postHref={itemHref}
-              did={item.post.author.did}
-              showFollowBtn={showFollowBtn}
+              numberOfLines={1}
+              text={sanitizeDisplayName(
+                item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
+              )}
+              href={`/profile/${item.reasonRepost.by.handle}`}
             />
-            {!isThreadChild && replyAuthorDid !== '' && (
-              <View style={[s.flexRow, s.mb2, s.alignCenter]}>
-                <FontAwesomeIcon
-                  icon="reply"
-                  size={9}
-                  style={[
-                    {color: pal.colors.textLight} as FontAwesomeIconStyle,
-                    s.mr5,
-                  ]}
-                />
-                <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}>
-                  Reply to
-                </Text>
-                <UserInfoText
-                  type="md"
-                  did={replyAuthorDid}
-                  attr="displayName"
-                  style={[pal.textLight, s.ml2]}
-                />
-              </View>
-            )}
+          </Text>
+        </Link>
+      )}
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <Link href={authorHref} title={item.post.author.handle} asAnchor>
+            <UserAvatar
+              size={52}
+              avatar={item.post.author.avatar}
+              hasWarning={!!item.post.author.labels?.length}
+            />
+          </Link>
+        </View>
+        <View style={styles.layoutContent}>
+          <PostMeta
+            authorHandle={item.post.author.handle}
+            authorDisplayName={item.post.author.displayName}
+            authorHasWarning={!!item.post.author.labels?.length}
+            timestamp={item.post.indexedAt}
+            postHref={itemHref}
+            did={item.post.author.did}
+            showFollowBtn={showFollowBtn}
+          />
+          {!isThreadChild && replyAuthorDid !== '' && (
+            <View style={[s.flexRow, s.mb2, s.alignCenter]}>
+              <FontAwesomeIcon
+                icon="reply"
+                size={9}
+                style={[
+                  {color: pal.colors.textLight} as FontAwesomeIconStyle,
+                  s.mr5,
+                ]}
+              />
+              <Text type="md" style={[pal.textLight, s.mr2]} lineHeight={1.2}>
+                Reply to
+              </Text>
+              <UserInfoText
+                type="md"
+                did={replyAuthorDid}
+                attr="displayName"
+                style={[pal.textLight, s.ml2]}
+              />
+            </View>
+          )}
+          <ContentHider
+            labels={item.post.labels}
+            containerStyle={styles.contentHider}>
             {item.richText?.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
@@ -228,36 +240,36 @@ export const FeedItem = observer(function ({
               </View>
             ) : undefined}
             <PostEmbeds embed={item.post.embed} style={styles.embed} />
-            <PostCtrls
-              style={styles.ctrls}
-              itemUri={itemUri}
-              itemCid={itemCid}
-              itemHref={itemHref}
-              itemTitle={itemTitle}
-              author={{
-                avatar: item.post.author.avatar!,
-                handle: item.post.author.handle,
-                displayName: item.post.author.displayName!,
-              }}
-              text={item.richText?.text || record.text}
-              indexedAt={item.post.indexedAt}
-              isAuthor={item.post.author.did === store.me.did}
-              replyCount={item.post.replyCount}
-              repostCount={item.post.repostCount}
-              likeCount={item.post.likeCount}
-              isReposted={!!item.post.viewer?.repost}
-              isLiked={!!item.post.viewer?.like}
-              onPressReply={onPressReply}
-              onPressToggleRepost={onPressToggleRepost}
-              onPressToggleLike={onPressToggleLike}
-              onCopyPostText={onCopyPostText}
-              onOpenTranslate={onOpenTranslate}
-              onDeletePost={onDeletePost}
-            />
-          </View>
+          </ContentHider>
+          <PostCtrls
+            style={styles.ctrls}
+            itemUri={itemUri}
+            itemCid={itemCid}
+            itemHref={itemHref}
+            itemTitle={itemTitle}
+            author={{
+              avatar: item.post.author.avatar!,
+              handle: item.post.author.handle,
+              displayName: item.post.author.displayName!,
+            }}
+            text={item.richText?.text || record.text}
+            indexedAt={item.post.indexedAt}
+            isAuthor={item.post.author.did === store.me.did}
+            replyCount={item.post.replyCount}
+            repostCount={item.post.repostCount}
+            likeCount={item.post.likeCount}
+            isReposted={!!item.post.viewer?.repost}
+            isLiked={!!item.post.viewer?.like}
+            onPressReply={onPressReply}
+            onPressToggleRepost={onPressToggleRepost}
+            onPressToggleLike={onPressToggleLike}
+            onCopyPostText={onCopyPostText}
+            onOpenTranslate={onOpenTranslate}
+            onDeletePost={onDeletePost}
+          />
         </View>
-      </Link>
-    </PostMutedWrapper>
+      </View>
+    </PostHider>
   )
 })
 
@@ -320,6 +332,9 @@ const styles = StyleSheet.create({
     flexWrap: 'wrap',
     paddingBottom: 4,
   },
+  contentHider: {
+    marginTop: 4,
+  },
   embed: {
     marginBottom: 6,
   },
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index dfbc2ddbd..d14d5e16d 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} from '@atproto/api'
+import {AppBskyActorDefs, ComAtprotoLabelDefs} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -17,6 +17,7 @@ export function ProfileCard({
   displayName,
   avatar,
   description,
+  labels,
   isFollowedBy,
   noBg,
   noBorder,
@@ -28,6 +29,7 @@ export function ProfileCard({
   displayName?: string
   avatar?: string
   description?: string
+  labels: ComAtprotoLabelDefs.Label[] | undefined
   isFollowedBy?: boolean
   noBg?: boolean
   noBorder?: boolean
@@ -50,7 +52,7 @@ export function ProfileCard({
       asAnchor>
       <View style={styles.layout}>
         <View style={styles.layoutAvi}>
-          <UserAvatar size={40} avatar={avatar} />
+          <UserAvatar size={40} avatar={avatar} hasWarning={!!labels?.length} />
         </View>
         <View style={styles.layoutContent}>
           <Text
@@ -114,6 +116,7 @@ export const ProfileCardWithFollowBtn = observer(
     displayName,
     avatar,
     description,
+    labels,
     isFollowedBy,
     noBg,
     noBorder,
@@ -124,6 +127,7 @@ export const ProfileCardWithFollowBtn = observer(
     displayName?: string
     avatar?: string
     description?: string
+    labels: ComAtprotoLabelDefs.Label[] | undefined
     isFollowedBy?: boolean
     noBg?: boolean
     noBorder?: boolean
@@ -138,6 +142,7 @@ export const ProfileCardWithFollowBtn = observer(
         displayName={displayName}
         avatar={avatar}
         description={description}
+        labels={labels}
         isFollowedBy={isFollowedBy}
         noBg={noBg}
         noBorder={noBorder}
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 0ef652a98..db592075a 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -67,6 +67,7 @@ export const ProfileFollowers = observer(function ProfileFollowers({
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
+      labels={item.labels}
       isFollowedBy={!!item.viewer?.followedBy}
     />
   )
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index 54b5a319a..10da79c5e 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -64,6 +64,7 @@ export const ProfileFollows = observer(function ProfileFollows({
       handle={item.handle}
       displayName={item.displayName}
       avatar={item.avatar}
+      labels={item.labels}
       isFollowedBy={!!item.viewer?.followedBy}
     />
   )
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 1326d3ec3..d520a712f 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -27,6 +27,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 {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics'
 import {NavigationProp} from 'lib/routes/types'
@@ -320,6 +321,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
             richText={view.descriptionRichText}
           />
         ) : undefined}
+        <ProfileHeaderLabels labels={view.labels} />
         {view.viewer.muted ? (
           <View
             testID="profileHeaderMutedNotice"
@@ -348,7 +350,11 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoaded({
         onPress={onPressAvi}>
         <View
           style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-          <UserAvatar size={80} avatar={view.avatar} />
+          <UserAvatar
+            size={80}
+            avatar={view.avatar}
+            hasWarning={!!view.labels?.length}
+          />
         </View>
       </TouchableWithoutFeedback>
     </View>
diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx
index b53965f44..5d6163d4b 100644
--- a/src/view/com/search/SearchResults.tsx
+++ b/src/view/com/search/SearchResults.tsx
@@ -101,6 +101,7 @@ const Profiles = observer(({model}: {model: SearchUIModel}) => {
           displayName={item.displayName}
           avatar={item.avatar}
           description={item.description}
+          labels={item.labels}
         />
       ))}
       <View style={s.footerSpacer} />
diff --git a/src/view/com/util/LoadLatestBtn.tsx b/src/view/com/util/LoadLatestBtn.tsx
index fd05ecc9c..88b6dffd9 100644
--- a/src/view/com/util/LoadLatestBtn.tsx
+++ b/src/view/com/util/LoadLatestBtn.tsx
@@ -10,31 +10,33 @@ import {useStores} from 'state/index'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
-export const LoadLatestBtn = observer(({onPress}: {onPress: () => void}) => {
-  const store = useStores()
-  const safeAreaInsets = useSafeAreaInsets()
-  return (
-    <TouchableOpacity
-      style={[
-        styles.loadLatest,
-        !store.shell.minimalShellMode && {
-          bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
-        },
-      ]}
-      onPress={onPress}
-      hitSlop={HITSLOP}>
-      <LinearGradient
-        colors={[gradients.blueLight.start, gradients.blueLight.end]}
-        start={{x: 0, y: 0}}
-        end={{x: 1, y: 1}}
-        style={styles.loadLatestInner}>
-        <Text type="md-bold" style={styles.loadLatestText}>
-          Load new posts
-        </Text>
-      </LinearGradient>
-    </TouchableOpacity>
-  )
-})
+export const LoadLatestBtn = observer(
+  ({onPress, label}: {onPress: () => void; label: string}) => {
+    const store = useStores()
+    const safeAreaInsets = useSafeAreaInsets()
+    return (
+      <TouchableOpacity
+        style={[
+          styles.loadLatest,
+          !store.shell.minimalShellMode && {
+            bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30),
+          },
+        ]}
+        onPress={onPress}
+        hitSlop={HITSLOP}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={styles.loadLatestInner}>
+          <Text type="md-bold" style={styles.loadLatestText}>
+            Load new {label}
+          </Text>
+        </LinearGradient>
+      </TouchableOpacity>
+    )
+  },
+)
 
 const styles = StyleSheet.create({
   loadLatest: {
diff --git a/src/view/com/util/LoadLatestBtn.web.tsx b/src/view/com/util/LoadLatestBtn.web.tsx
index ba33f92a7..c85f44f30 100644
--- a/src/view/com/util/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/LoadLatestBtn.web.tsx
@@ -6,7 +6,13 @@ import {UpIcon} from 'lib/icons'
 
 const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
 
-export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
+export const LoadLatestBtn = ({
+  onPress,
+  label,
+}: {
+  onPress: () => void
+  label: string
+}) => {
   const pal = usePalette('default')
   return (
     <TouchableOpacity
@@ -15,7 +21,7 @@ export const LoadLatestBtn = ({onPress}: {onPress: () => void}) => {
       hitSlop={HITSLOP}>
       <Text type="md-bold" style={pal.text}>
         <UpIcon size={16} strokeWidth={1} style={[pal.text, styles.icon]} />
-        Load new posts
+        Load new {label}
       </Text>
     </TouchableOpacity>
   )
@@ -25,7 +31,9 @@ const styles = StyleSheet.create({
   loadLatest: {
     flexDirection: 'row',
     position: 'absolute',
-    left: 'calc(50vw - 80px)',
+    left: '50vw',
+    // @ts-ignore web only -prf
+    transform: 'translateX(-50%)',
     top: 30,
     shadowColor: '#000',
     shadowOpacity: 0.2,
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index c46c16da0..d9dd11e05 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -15,6 +15,7 @@ interface PostMetaOpts {
   authorAvatar?: string
   authorHandle: string
   authorDisplayName: string | undefined
+  authorHasWarning: boolean
   postHref: string
   timestamp: string
   did?: string
@@ -93,7 +94,11 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
     <View style={styles.meta}>
       {typeof opts.authorAvatar !== 'undefined' && (
         <View style={[styles.metaItem, styles.avatar]}>
-          <UserAvatar avatar={opts.authorAvatar} size={16} />
+          <UserAvatar
+            avatar={opts.authorAvatar}
+            size={16}
+            hasWarning={opts.authorHasWarning}
+          />
         </View>
       )}
       <View style={[styles.metaItem, styles.maxWidth]}>
diff --git a/src/view/com/util/PostMuted.tsx b/src/view/com/util/PostMuted.tsx
deleted file mode 100644
index 539a71ecf..000000000
--- a/src/view/com/util/PostMuted.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from './text/Text'
-
-export function PostMutedWrapper({
-  isMuted,
-  children,
-}: React.PropsWithChildren<{isMuted?: boolean}>) {
-  const pal = usePalette('default')
-  const [override, setOverride] = React.useState(false)
-  if (!isMuted || override) {
-    return <>{children}</>
-  }
-  return (
-    <View style={[styles.container, pal.view, 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(true)}>
-        <Text type="md" style={pal.link}>
-          Show post
-        </Text>
-      </TouchableOpacity>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 14,
-    paddingHorizontal: 18,
-    borderTopWidth: 1,
-  },
-  icon: {
-    marginRight: 10,
-  },
-  showBtn: {
-    marginLeft: 'auto',
-  },
-})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index ff741cd34..d18c2d697 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -44,10 +44,12 @@ function DefaultAvatar({size}: {size: number}) {
 export function UserAvatar({
   size,
   avatar,
+  hasWarning,
   onSelectNewAvatar,
 }: {
   size: number
   avatar?: string | null
+  hasWarning?: boolean
   onSelectNewAvatar?: (img: PickedMedia | null) => void
 }) {
   const store = useStores()
@@ -105,6 +107,22 @@ export function UserAvatar({
       },
     },
   ]
+
+  const warning = React.useMemo(() => {
+    if (!hasWarning) {
+      return <></>
+    }
+    return (
+      <View style={[styles.warningIconContainer, pal.view]}>
+        <FontAwesomeIcon
+          icon="exclamation-circle"
+          style={styles.warningIcon}
+          size={Math.floor(size / 3)}
+        />
+      </View>
+    )
+  }, [hasWarning, size, pal])
+
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
     <DropdownButton
@@ -137,14 +155,20 @@ export function UserAvatar({
       </View>
     </DropdownButton>
   ) : avatar ? (
-    <HighPriorityImage
-      testID="userAvatarImage"
-      style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
-      resizeMode="stretch"
-      source={{uri: avatar}}
-    />
+    <View style={{width: size, height: size}}>
+      <HighPriorityImage
+        testID="userAvatarImage"
+        style={{width: size, height: size, borderRadius: Math.floor(size / 2)}}
+        resizeMode="stretch"
+        source={{uri: avatar}}
+      />
+      {warning}
+    </View>
   ) : (
-    <DefaultAvatar size={size} />
+    <View style={{width: size, height: size}}>
+      <DefaultAvatar size={size} />
+      {warning}
+    </View>
   )
 }
 
@@ -165,4 +189,13 @@ const styles = StyleSheet.create({
     height: 80,
     borderRadius: 40,
   },
+  warningIconContainer: {
+    position: 'absolute',
+    right: 0,
+    bottom: 0,
+    borderRadius: 100,
+  },
+  warningIcon: {
+    color: colors.red3,
+  },
 })
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
new file mode 100644
index 000000000..f65635d35
--- /dev/null
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  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'
+
+export function ContentHider({
+  testID,
+  isMuted,
+  labels,
+  style,
+  containerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  isMuted?: boolean
+  labels: ComAtprotoLabelDefs.Label[] | undefined
+  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') {
+    return (
+      <View testID={testID} style={style}>
+        {children}
+      </View>
+    )
+  }
+
+  if (labelPref.pref === 'hide') {
+    return <></>
+  }
+
+  return (
+    <View style={[styles.container, pal.view, pal.border, containerStyle]}>
+      <View
+        style={[
+          styles.description,
+          pal.viewLight,
+          override && styles.descriptionOpen,
+        ]}>
+        <Text type="md" style={pal.textLight}>
+          {isMuted ? (
+            <>Post from an account you muted.</>
+          ) : (
+            <>Warning: {labelPref.desc.title}</>
+          )}
+        </Text>
+        <TouchableOpacity
+          style={styles.showBtn}
+          onPress={() => setOverride(v => !v)}>
+          <Text type="md" style={pal.link}>
+            {override ? 'Hide' : 'Show'}
+          </Text>
+        </TouchableOpacity>
+      </View>
+      {override && (
+        <View style={[styles.childrenContainer, pal.border]}>
+          <View testID={testID} style={addStyle(style, styles.child)}>
+            {children}
+          </View>
+        </View>
+      )}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    marginBottom: 10,
+    borderWidth: 1,
+    borderRadius: 12,
+  },
+  description: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 14,
+    paddingLeft: 14,
+    paddingRight: 18,
+    borderRadius: 12,
+  },
+  descriptionOpen: {
+    borderBottomLeftRadius: 0,
+    borderBottomRightRadius: 0,
+  },
+  icon: {
+    marginRight: 10,
+  },
+  showBtn: {
+    marginLeft: 'auto',
+  },
+  childrenContainer: {
+    paddingHorizontal: 12,
+    paddingTop: 8,
+  },
+  child: {},
+})
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
new file mode 100644
index 000000000..bafc7aecf
--- /dev/null
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  TouchableOpacity,
+  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'
+
+export function PostHider({
+  testID,
+  href,
+  isMuted,
+  labels,
+  style,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  href: string
+  isMuted: boolean | undefined
+  labels: ComAtprotoLabelDefs.Label[] | undefined
+  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 (!isMuted) {
+    // NOTE: any further label enforcement should occur in ContentContainer
+    return (
+      <Link testID={testID} style={style} href={href} noFeedback>
+        {children}
+      </Link>
+    )
+  }
+
+  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>
+      )}
+    </>
+  )
+}
+
+const styles = StyleSheet.create({
+  description: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingVertical: 14,
+    paddingHorizontal: 18,
+    borderTopWidth: 1,
+  },
+  icon: {
+    marginRight: 10,
+  },
+  showBtn: {
+    marginLeft: 'auto',
+  },
+  childrenContainer: {
+    paddingHorizontal: 6,
+    paddingBottom: 6,
+  },
+  child: {
+    borderWidth: 1,
+    borderRadius: 12,
+  },
+})
diff --git a/src/view/com/util/moderation/ProfileHeaderLabels.tsx b/src/view/com/util/moderation/ProfileHeaderLabels.tsx
new file mode 100644
index 000000000..e099f09a7
--- /dev/null
+++ b/src/view/com/util/moderation/ProfileHeaderLabels.tsx
@@ -0,0 +1,55 @@
+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.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/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 5a8be5a14..94e837238 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -42,6 +42,7 @@ export function QuoteEmbed({
         authorAvatar={quote.author.avatar}
         authorHandle={quote.author.handle}
         authorDisplayName={quote.author.displayName}
+        authorHasWarning={false}
         postHref={itemHref}
         timestamp={quote.indexedAt}
       />
diff --git a/src/view/index.ts b/src/view/index.ts
index 47a5f8acf..e6e342697 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -34,6 +34,7 @@ import {faCompass} from '@fortawesome/free-regular-svg-icons/faCompass'
 import {faEllipsis} from '@fortawesome/free-solid-svg-icons/faEllipsis'
 import {faEnvelope} from '@fortawesome/free-solid-svg-icons/faEnvelope'
 import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
+import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
 import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash'
 import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
 import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
@@ -106,8 +107,8 @@ export function setup() {
     faCompass,
     faEllipsis,
     faEnvelope,
+    faEye,
     faExclamation,
-    faQuoteLeft,
     farEyeSlash,
     faGear,
     faGlobe,
@@ -128,6 +129,7 @@ export function setup() {
     faPenNib,
     faPenToSquare,
     faPlus,
+    faQuoteLeft,
     faReply,
     faRetweet,
     faRss,
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 260df0401..fac522c68 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -194,7 +194,7 @@ const FeedPage = observer(
           headerOffset={HEADER_OFFSET}
         />
         {feed.hasNewLatest && !feed.isRefreshing && (
-          <LoadLatestBtn onPress={onPressLoadLatest} />
+          <LoadLatestBtn onPress={onPressLoadLatest} label="posts" />
         )}
         <FAB
           testID="composeFAB"
diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx
index 2a2d3c13f..8fc47b248 100644
--- a/src/view/screens/Notifications.tsx
+++ b/src/view/screens/Notifications.tsx
@@ -1,8 +1,7 @@
-import React, {useEffect} from 'react'
+import React from 'react'
 import {FlatList, View} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {observer} from 'mobx-react-lite'
-import useAppState from 'react-native-appstate-hook'
 import {
   NativeStackScreenProps,
   NotificationsTabNavigatorParams,
@@ -11,13 +10,12 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Feed} from '../com/notifications/Feed'
 import {InvitedUsers} from '../com/notifications/InvitedUsers'
+import {LoadLatestBtn} from 'view/com/util/LoadLatestBtn'
 import {useStores} from 'state/index'
 import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
 import {s} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
 
-const NOTIFICATIONS_POLL_INTERVAL = 15e3
-
 type Props = NativeStackScreenProps<
   NotificationsTabNavigatorParams,
   'Notifications'
@@ -28,46 +26,21 @@ export const NotificationsScreen = withAuthRequired(
     const onMainScroll = useOnMainScroll(store)
     const scrollElRef = React.useRef<FlatList>(null)
     const {screen} = useAnalytics()
-    const {appState} = useAppState({
-      onForeground: () => doPoll(true),
-    })
 
     // event handlers
     // =
-    const onPressTryAgain = () => {
+    const onPressTryAgain = React.useCallback(() => {
       store.me.notifications.refresh()
-    }
+    }, [store])
+
     const scrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: 0})
     }, [scrollElRef])
 
-    // periodic polling
-    // =
-    const doPoll = React.useCallback(
-      async (isForegrounding = false) => {
-        if (isForegrounding) {
-          // app is foregrounding, refresh optimistically
-          store.log.debug('NotificationsScreen: Refreshing on app foreground')
-          await Promise.all([
-            store.me.notifications.loadUnreadCount(),
-            store.me.notifications.refresh(),
-          ])
-        } else if (appState === 'active') {
-          // periodic poll, refresh if there are new notifs
-          store.log.debug('NotificationsScreen: Polling for new notifications')
-          const didChange = await store.me.notifications.loadUnreadCount()
-          if (didChange) {
-            store.log.debug('NotificationsScreen: Loading new notifications')
-            await store.me.notifications.loadLatest()
-          }
-        }
-      },
-      [appState, store],
-    )
-    useEffect(() => {
-      const pollInterval = setInterval(doPoll, NOTIFICATIONS_POLL_INTERVAL)
-      return () => clearInterval(pollInterval)
-    }, [doPoll])
+    const onPressLoadLatest = React.useCallback(() => {
+      store.me.notifications.processQueue()
+      scrollToTop()
+    }, [store, scrollToTop])
 
     // on-visible setup
     // =
@@ -75,16 +48,16 @@ export const NotificationsScreen = withAuthRequired(
       React.useCallback(() => {
         store.shell.setMinimalShellMode(false)
         store.log.debug('NotificationsScreen: Updating feed')
-        const softResetSub = store.onScreenSoftReset(scrollToTop)
-        store.me.notifications.loadUnreadCount()
-        store.me.notifications.loadLatest()
+        const softResetSub = store.onScreenSoftReset(onPressLoadLatest)
+        store.me.notifications.syncQueue()
+        store.me.notifications.update()
         screen('Notifications')
 
         return () => {
           softResetSub.remove()
-          store.me.notifications.markAllRead()
+          store.me.notifications.markAllUnqueuedRead()
         }
-      }, [store, screen, scrollToTop]),
+      }, [store, screen, onPressLoadLatest]),
     )
 
     return (
@@ -97,6 +70,10 @@ export const NotificationsScreen = withAuthRequired(
           onScroll={onMainScroll}
           scrollElRef={scrollElRef}
         />
+        {store.me.notifications.hasNewLatest &&
+          !store.me.notifications.isRefreshing && (
+            <LoadLatestBtn onPress={onPressLoadLatest} label="notifications" />
+          )}
       </View>
     )
   }),
diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx
index e1fb3ec0a..ed9effd0b 100644
--- a/src/view/screens/Search.tsx
+++ b/src/view/screens/Search.tsx
@@ -155,6 +155,7 @@ export const SearchScreen = withAuthRequired(
                       testID={`searchAutoCompleteResult-${item.handle}`}
                       handle={item.handle}
                       displayName={item.displayName}
+                      labels={item.labels}
                       avatar={item.avatar}
                     />
                   ))}
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index d429db1b6..081be8dca 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -124,6 +124,11 @@ export const SettingsScreen = withAuthRequired(
       store.shell.openModal({name: 'invite-codes'})
     }, [track, store])
 
+    const onPressContentFiltering = React.useCallback(() => {
+      track('Settings:ContentfilteringButtonClicked')
+      store.shell.openModal({name: 'content-filtering-settings'})
+    }, [track, store])
+
     const onPressSignout = React.useCallback(() => {
       track('Settings:SignOutButtonClicked')
       store.session.logout()
@@ -249,6 +254,20 @@ export const SettingsScreen = withAuthRequired(
             Advanced
           </Text>
           <TouchableOpacity
+            testID="contentFilteringBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            onPress={isSwitching ? undefined : onPressContentFiltering}>
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon="eye"
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Content moderation
+            </Text>
+          </TouchableOpacity>
+          <TouchableOpacity
             testID="changeHandleBtn"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             onPress={isSwitching ? undefined : onPressChangeHandle}>
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 1bc12add1..995471944 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -90,6 +90,7 @@ export const DesktopSearch = observer(function DesktopSearch() {
                   handle={item.handle}
                   displayName={item.displayName}
                   avatar={item.avatar}
+                  labels={item.labels}
                   noBorder={i === 0}
                 />
               ))}