about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx5
-rw-r--r--src/lib/api/feed-manip.ts6
-rw-r--r--src/lib/api/hack-add-deleted-embed.ts24
-rw-r--r--src/lib/api/index.ts38
-rw-r--r--src/lib/icons.tsx38
-rw-r--r--src/lib/moderation.ts107
-rw-r--r--src/lib/sentry.ts5
-rw-r--r--src/lib/strings/display-names.ts11
-rw-r--r--src/lib/strings/handles.ts2
-rw-r--r--src/lib/strings/rich-text-manip.ts34
-rw-r--r--src/lib/strings/url-helpers.ts15
-rw-r--r--src/locale/helpers.ts6
-rw-r--r--src/state/models/cache/handle-resolutions.ts5
-rw-r--r--src/state/models/cache/posts.ts70
-rw-r--r--src/state/models/cache/profiles-view.ts4
-rw-r--r--src/state/models/content/list.ts2
-rw-r--r--src/state/models/content/post-thread-item.ts8
-rw-r--r--src/state/models/content/post-thread.ts101
-rw-r--r--src/state/models/content/profile.ts58
-rw-r--r--src/state/models/discovery/foafs.ts29
-rw-r--r--src/state/models/discovery/suggested-actors.ts22
-rw-r--r--src/state/models/feeds/notifications.ts77
-rw-r--r--src/state/models/feeds/post.ts46
-rw-r--r--src/state/models/feeds/posts-slice.ts34
-rw-r--r--src/state/models/feeds/posts.ts64
-rw-r--r--src/state/models/me.ts4
-rw-r--r--src/state/models/media/image.ts4
-rw-r--r--src/state/models/root-store.ts4
-rw-r--r--src/state/models/ui/preferences.ts56
-rw-r--r--src/state/models/ui/profile.ts61
-rw-r--r--src/state/models/ui/shell.ts35
-rw-r--r--src/view/com/composer/Composer.tsx160
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx64
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx32
-rw-r--r--src/view/com/feeds/CustomFeed.tsx1
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx5
-rw-r--r--src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx5
-rw-r--r--src/view/com/lists/ListActions.tsx13
-rw-r--r--src/view/com/lists/ListItems.tsx8
-rw-r--r--src/view/com/lists/ListsList.tsx14
-rw-r--r--src/view/com/modals/ChangeHandle.tsx4
-rw-r--r--src/view/com/modals/InviteCodes.tsx6
-rw-r--r--src/view/com/modals/ListAddRemoveUser.tsx65
-rw-r--r--src/view/com/modals/Modal.tsx42
-rw-r--r--src/view/com/modals/Modal.web.tsx15
-rw-r--r--src/view/com/modals/ModerationDetails.tsx105
-rw-r--r--src/view/com/modals/ProfilePreview.tsx91
-rw-r--r--src/view/com/modals/SelfLabel.tsx191
-rw-r--r--src/view/com/modals/report/Modal.tsx (renamed from src/view/com/modals/report/ReportPost.tsx)165
-rw-r--r--src/view/com/modals/report/ReasonOptions.tsx123
-rw-r--r--src/view/com/modals/report/ReportAccount.tsx197
-rw-r--r--src/view/com/modals/report/types.ts8
-rw-r--r--src/view/com/notifications/FeedItem.tsx24
-rw-r--r--src/view/com/post-thread/PostThread.tsx90
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx483
-rw-r--r--src/view/com/post/Post.tsx48
-rw-r--r--src/view/com/posts/FeedItem.tsx215
-rw-r--r--src/view/com/posts/FeedSlice.tsx129
-rw-r--r--src/view/com/profile/ProfileCard.tsx96
-rw-r--r--src/view/com/profile/ProfileHeader.tsx76
-rw-r--r--src/view/com/util/PostMeta.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx22
-rw-r--r--src/view/com/util/UserBanner.tsx4
-rw-r--r--src/view/com/util/UserPreviewLink.tsx4
-rw-r--r--src/view/com/util/ViewSelector.tsx91
-rw-r--r--src/view/com/util/forms/NativeDropdown.tsx4
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx20
-rw-r--r--src/view/com/util/forms/SelectableBtn.tsx23
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx127
-rw-r--r--src/view/com/util/moderation/ImageHider.tsx80
-rw-r--r--src/view/com/util/moderation/PostAlerts.tsx68
-rw-r--r--src/view/com/util/moderation/PostHider.tsx131
-rw-r--r--src/view/com/util/moderation/ProfileHeaderAlerts.tsx76
-rw-r--r--src/view/com/util/moderation/ProfileHeaderWarnings.tsx44
-rw-r--r--src/view/com/util/moderation/ScreenHider.tsx57
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx80
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.tsx9
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx4
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx59
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx85
-rw-r--r--src/view/com/util/post-embeds/index.tsx98
-rw-r--r--src/view/index.ts6
-rw-r--r--src/view/screens/CustomFeed.tsx186
-rw-r--r--src/view/screens/DiscoverFeeds.tsx11
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx1
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx1
-rw-r--r--src/view/screens/Profile.tsx4
-rw-r--r--src/view/screens/ProfileList.tsx12
88 files changed, 2979 insertions, 1780 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 06cce0f00..48bab182d 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -125,7 +125,10 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
       <Stack.Screen
         name="Profile"
         component={ProfileScreen}
-        options={({route}) => ({title: title(`@${route.params.name}`)})}
+        options={({route}) => ({
+          title: title(`@${route.params.name}`),
+          animation: 'none',
+        })}
       />
       <Stack.Screen
         name="ProfileFollowers"
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index f4bf6cdff..472289b40 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -17,6 +17,12 @@ export class FeedViewPostsSlice {
 
   constructor(public items: FeedViewPost[] = []) {}
 
+  get _reactKey() {
+    return `slice-${this.items[0].post.uri}-${
+      this.items[0].reason?.indexedAt || this.items[0].post.indexedAt
+    }`
+  }
+
   get uri() {
     if (this.isFlattenedReply) {
       return this.items[1].post.uri
diff --git a/src/lib/api/hack-add-deleted-embed.ts b/src/lib/api/hack-add-deleted-embed.ts
new file mode 100644
index 000000000..59aad21a2
--- /dev/null
+++ b/src/lib/api/hack-add-deleted-embed.ts
@@ -0,0 +1,24 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  ComAtprotoRepoStrongRef,
+} from '@atproto/api'
+
+/**
+ * HACK
+ * The server doesnt seem to be correctly giving the notFound view yet
+ * so I'm adding it manually for now
+ * -prf
+ */
+export function hackAddDeletedEmbed(post: AppBskyFeedDefs.PostView) {
+  const record = post.record as AppBskyFeedPost.Record
+  if (record.embed?.$type === 'app.bsky.embed.record' && !post.embed) {
+    post.embed = {
+      $type: 'app.bsky.embed.record#view',
+      record: {
+        $type: 'app.bsky.embed.record#viewNotFound',
+        uri: (record.embed.record as ComAtprotoRepoStrongRef.Main).uri,
+      },
+    }
+  }
+}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 458ef7baa..4ecd32046 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyRichtextFacet,
+  ComAtprotoLabelDefs,
   ComAtprotoRepoUploadBlob,
   RichText,
 } from '@atproto/api'
@@ -13,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors'
 import {LinkMeta} from '../link-meta/link-meta'
 import {isWeb} from 'platform/detection'
 import {ImageModel} from 'state/models/media/image'
+import {shortenLinks} from 'lib/strings/rich-text-manip'
 
 export interface ExternalEmbedDraft {
   uri: string
@@ -29,10 +31,24 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
   if (didOrHandle.startsWith('did:')) {
     return didOrHandle
   }
-  const res = await store.agent.resolveHandle({
-    handle: didOrHandle,
-  })
-  return res.data.did
+
+  // we run the resolution always to ensure freshness
+  const promise = store.agent
+    .resolveHandle({
+      handle: didOrHandle,
+    })
+    .then(res => {
+      store.handleResolutions.cache.set(didOrHandle, res.data.did)
+      return res.data.did
+    })
+
+  // but we can return immediately if it's cached
+  const cached = store.handleResolutions.cache.get(didOrHandle)
+  if (cached) {
+    return cached
+  }
+
+  return promise
 }
 
 export async function uploadBlob(
@@ -63,6 +79,7 @@ interface PostOpts {
   }
   extLink?: ExternalEmbedDraft
   images?: ImageModel[]
+  labels?: string[]
   knownHandles?: Set<string>
   onStateChange?: (state: string) => void
   langs?: string[]
@@ -76,7 +93,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     | AppBskyEmbedRecordWithMedia.Main
     | undefined
   let reply
-  const rt = new RichText(
+  let rt = new RichText(
     {text: opts.rawText.trim()},
     {
       cleanNewlines: true,
@@ -85,6 +102,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
 
   opts.onStateChange?.('Processing...')
   await rt.detectFacets(store.agent)
+  rt = shortenLinks(rt)
 
   // filter out any mention facets that didn't map to a user
   rt.facets = rt.facets?.filter(facet => {
@@ -220,6 +238,15 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     }
   }
 
+  // set labels
+  let labels: ComAtprotoLabelDefs.SelfLabels | undefined
+  if (opts.labels?.length) {
+    labels = {
+      $type: 'com.atproto.label.defs#selfLabels',
+      values: opts.labels.map(val => ({val})),
+    }
+  }
+
   // add top 3 languages from user preferences if langs is provided
   let langs = opts.langs
   if (opts.langs) {
@@ -234,6 +261,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
       reply,
       embed,
       langs,
+      labels,
     })
   } catch (e: any) {
     console.error(`Failed to create post: ${e.toString()}`)
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 4ea3b4d65..233f8a473 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -957,3 +957,41 @@ export function SatelliteDishIcon({
     </Svg>
   )
 }
+
+// Copyright (c) 2020 Refactoring UI Inc.
+// https://github.com/tailwindlabs/heroicons/blob/master/LICENSE
+export function ShieldExclamation({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  let color = 'currentColor'
+  if (
+    style &&
+    typeof style === 'object' &&
+    'color' in style &&
+    typeof style.color === 'string'
+  ) {
+    color = style.color
+  }
+  return (
+    <Svg
+      width={size}
+      height={size}
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1.5}
+      stroke={color}
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
+      />
+    </Svg>
+  )
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
new file mode 100644
index 000000000..aadee0e74
--- /dev/null
+++ b/src/lib/moderation.ts
@@ -0,0 +1,107 @@
+import {ModerationCause, ProfileModeration} from '@atproto/api'
+
+export interface ModerationCauseDescription {
+  name: string
+  description: string
+}
+
+export function describeModerationCause(
+  cause: ModerationCause | undefined,
+  context: 'account' | 'content',
+): ModerationCauseDescription {
+  if (!cause) {
+    return {
+      name: 'Content Warning',
+      description:
+        'Moderator has chosen to set a general warning on the content.',
+    }
+  }
+  if (cause.type === 'blocking') {
+    return {
+      name: 'User Blocked',
+      description: 'You have blocked this user. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'blocked-by') {
+    return {
+      name: 'User Blocking You',
+      description: 'This user has blocked you. You cannot view their content.',
+    }
+  }
+  if (cause.type === 'block-other') {
+    return {
+      name: 'Content Not Available',
+      description:
+        'This content is not available because one of the users involved has blocked the other.',
+    }
+  }
+  if (cause.type === 'muted') {
+    if (cause.source.type === 'list') {
+      return {
+        name:
+          context === 'account'
+            ? `Muted by "${cause.source.list.name}"`
+            : `Post by muted user ("${cause.source.list.name}")`,
+        description: 'You have muted this user',
+      }
+    } else {
+      return {
+        name: context === 'account' ? 'Muted User' : 'Post by muted user',
+        description: 'You have muted this user',
+      }
+    }
+  }
+  return cause.labelDef.strings[context].en
+}
+
+export function getProfileModerationCauses(
+  moderation: ProfileModeration,
+): ModerationCause[] {
+  /*
+  Gather everything on profile and account that blurs or alerts
+  */
+  return [
+    moderation.decisions.profile.cause,
+    ...moderation.decisions.profile.additionalCauses,
+    moderation.decisions.account.cause,
+    ...moderation.decisions.account.additionalCauses,
+  ].filter(cause => {
+    if (!cause) {
+      return false
+    }
+    if (cause?.type === 'label') {
+      if (
+        cause.labelDef.onwarn === 'blur' ||
+        cause.labelDef.onwarn === 'alert'
+      ) {
+        return true
+      } else {
+        return false
+      }
+    }
+    return true
+  }) as ModerationCause[]
+}
+
+export function isCauseALabelOnUri(
+  cause: ModerationCause | undefined,
+  uri: string,
+): boolean {
+  if (cause?.type !== 'label') {
+    return false
+  }
+  return cause.label.uri === uri
+}
+
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.labeler.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
+  }
+  return `${cause.type}:${source}`
+}
diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts
index c5d1d3eb6..5448415ff 100644
--- a/src/lib/sentry.ts
+++ b/src/lib/sentry.ts
@@ -15,6 +15,11 @@ Sentry.init({
   environment: __DEV__ ? 'development' : 'production', // Set the environment
   enableAutoPerformanceTracking: true, // Enable auto performance tracking
   tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
+  _experiments: {
+    // The sampling rate for profiling is relative to TracesSampleRate.
+    // In this case, we'll capture profiles for 50% of transactions.
+    profilesSampleRate: 0.5,
+  },
   integrations: isNative
     ? [
         new Sentry.Native.ReactNativeTracing({
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index b98153732..29b7c3b50 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,10 +1,19 @@
+import {ModerationUI} from '@atproto/api'
+import {describeModerationCause} from '../moderation'
+
 // \u2705 = ✅
 // \u2713 = ✓
 // \u2714 = ✔
 // \u2611 = ☑
 const CHECK_MARKS_RE = /[\u2705\u2713\u2714\u2611]/gu
 
-export function sanitizeDisplayName(str: string): string {
+export function sanitizeDisplayName(
+  str: string,
+  moderation?: ModerationUI,
+): string {
+  if (moderation?.blur) {
+    return `âš ${describeModerationCause(moderation.cause, 'account').name}`
+  }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').trim()
   }
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 3c01d9345..6ce462435 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -3,7 +3,7 @@ export function makeValidHandle(str: string): string {
     str = str.slice(0, 20)
   }
   str = str.toLowerCase()
-  return str.replace(/^[^a-z]+/g, '').replace(/[^a-z0-9-]/g, '')
+  return str.replace(/^[^a-z0-9]+/g, '').replace(/[^a-z0-9-]/g, '')
 }
 
 export function createFullHandle(name: string, domain: string): string {
diff --git a/src/lib/strings/rich-text-manip.ts b/src/lib/strings/rich-text-manip.ts
new file mode 100644
index 000000000..d9cd8c071
--- /dev/null
+++ b/src/lib/strings/rich-text-manip.ts
@@ -0,0 +1,34 @@
+import {RichText, UnicodeString} from '@atproto/api'
+import {toShortUrl} from './url-helpers'
+
+export function shortenLinks(rt: RichText): RichText {
+  if (!rt.facets?.length) {
+    return rt
+  }
+  rt = rt.clone()
+  // enumerate the link facets
+  if (rt.facets) {
+    for (const facet of rt.facets) {
+      const isLink = !!facet.features.find(
+        f => f.$type === 'app.bsky.richtext.facet#link',
+      )
+      if (!isLink) {
+        continue
+      }
+
+      // extract and shorten the URL
+      const {byteStart, byteEnd} = facet.index
+      const url = rt.unicodeText.slice(byteStart, byteEnd)
+      const shortened = new UnicodeString(toShortUrl(url))
+
+      // insert the shorten URL
+      rt.insert(byteStart, shortened.utf16)
+      // update the facet to cover the new shortened URL
+      facet.index.byteStart = byteStart
+      facet.index.byteEnd = byteStart + shortened.length
+      // remove the old URL
+      rt.delete(byteStart + shortened.length, byteEnd + shortened.length)
+    }
+  }
+  return rt
+}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 105c631bf..b509aad01 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -30,7 +30,7 @@ export function toNiceDomain(url: string): string {
     if (`https://${urlp.host}` === PROD_SERVICE) {
       return 'Bluesky Social'
     }
-    return urlp.host
+    return urlp.host ? urlp.host : url
   } catch (e) {
     return url
   }
@@ -42,15 +42,12 @@ export function toShortUrl(url: string): string {
     if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
       return url
     }
-    const shortened =
-      urlp.host +
-      (urlp.pathname === '/' ? '' : urlp.pathname) +
-      urlp.search +
-      urlp.hash
-    if (shortened.length > 30) {
-      return shortened.slice(0, 27) + '...'
+    const path =
+      (urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
+    if (path.length > 15) {
+      return urlp.host + path.slice(0, 13) + '...'
     }
-    return shortened ? shortened : url
+    return urlp.host + path
   } catch (e) {
     return url
   }
diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts
index bce4e6590..6e6e3f08c 100644
--- a/src/locale/helpers.ts
+++ b/src/locale/helpers.ts
@@ -79,8 +79,6 @@ export function isPostInLanguage(
   return bcp47Match.basicFilter(lang, targetLangs).length > 0
 }
 
-export function getTranslatorLink(lang: string, text: string): string {
-  return encodeURI(
-    `https://translate.google.com/?sl=auto&tl=${lang}&text=${text}`,
-  )
+export function getTranslatorLink(text: string): string {
+  return encodeURI(`https://translate.google.com/?sl=auto&text=${text}`)
 }
diff --git a/src/state/models/cache/handle-resolutions.ts b/src/state/models/cache/handle-resolutions.ts
new file mode 100644
index 000000000..2e2b69661
--- /dev/null
+++ b/src/state/models/cache/handle-resolutions.ts
@@ -0,0 +1,5 @@
+import {LRUMap} from 'lru_map'
+
+export class HandleResolutionsCache {
+  cache: LRUMap<string, string> = new LRUMap(500)
+}
diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts
new file mode 100644
index 000000000..d3632f436
--- /dev/null
+++ b/src/state/models/cache/posts.ts
@@ -0,0 +1,70 @@
+import {LRUMap} from 'lru_map'
+import {RootStoreModel} from '../root-store'
+import {
+  AppBskyFeedDefs,
+  AppBskyEmbedRecord,
+  AppBskyEmbedRecordWithMedia,
+  AppBskyFeedPost,
+} from '@atproto/api'
+
+type PostView = AppBskyFeedDefs.PostView
+
+export class PostsCache {
+  cache: LRUMap<string, PostView> = new LRUMap(500)
+
+  constructor(public rootStore: RootStoreModel) {}
+
+  set(uri: string, postView: PostView) {
+    this.cache.set(uri, postView)
+    if (postView.author.handle) {
+      this.rootStore.handleResolutions.cache.set(
+        postView.author.handle,
+        postView.author.did,
+      )
+    }
+  }
+
+  fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) {
+    this.set(feedItem.post.uri, feedItem.post)
+    if (
+      feedItem.reply?.parent &&
+      AppBskyFeedDefs.isPostView(feedItem.reply?.parent)
+    ) {
+      this.set(feedItem.reply.parent.uri, feedItem.reply.parent)
+    }
+    const embed = feedItem.post.embed
+    if (
+      AppBskyEmbedRecord.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.value) &&
+      AppBskyFeedPost.validateRecord(embed.record.value).success
+    ) {
+      this.set(embed.record.uri, embedViewToPostView(embed.record))
+    }
+    if (
+      AppBskyEmbedRecordWithMedia.isView(embed) &&
+      AppBskyEmbedRecord.isViewRecord(embed.record?.record) &&
+      AppBskyFeedPost.isRecord(embed.record.record.value) &&
+      AppBskyFeedPost.validateRecord(embed.record.record.value).success
+    ) {
+      this.set(
+        embed.record.record.uri,
+        embedViewToPostView(embed.record.record),
+      )
+    }
+  }
+}
+
+function embedViewToPostView(
+  embedView: AppBskyEmbedRecord.ViewRecord,
+): PostView {
+  return {
+    $type: 'app.bsky.feed.post#view',
+    uri: embedView.uri,
+    cid: embedView.cid,
+    author: embedView.author,
+    record: embedView.value,
+    indexedAt: embedView.indexedAt,
+    labels: embedView.labels,
+  }
+}
diff --git a/src/state/models/cache/profiles-view.ts b/src/state/models/cache/profiles-view.ts
index b4bd70db5..e5a9be587 100644
--- a/src/state/models/cache/profiles-view.ts
+++ b/src/state/models/cache/profiles-view.ts
@@ -45,8 +45,6 @@ export class ProfilesCache {
   }
 
   overwrite(did: string, res: GetProfile.Response) {
-    if (this.cache.has(did)) {
-      this.cache.set(did, res)
-    }
+    this.cache.set(did, res)
   }
 }
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index 2498cf581..5d4ffb4fa 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -306,7 +306,7 @@ export class ListModel {
     this.hasMore = !!this.loadMoreCursor
     this.list = res.data.list
     this.items = this.items.concat(
-      res.data.items.map(item => ({...item, _reactKey: item.subject})),
+      res.data.items.map(item => ({...item, _reactKey: item.subject.did})),
     )
   }
 }
diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts
index 14aa607ed..942f3acc8 100644
--- a/src/state/models/content/post-thread-item.ts
+++ b/src/state/models/content/post-thread-item.ts
@@ -3,9 +3,9 @@ import {
   AppBskyFeedPost as FeedPost,
   AppBskyFeedDefs,
   RichText,
+  PostModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
 import {PostsFeedItemModel} from '../feeds/post'
 
 type PostView = AppBskyFeedDefs.PostView
@@ -67,10 +67,6 @@ export class PostThreadItemModel {
     return this.data.isThreadMuted
   }
 
-  get labelInfo(): PostLabelInfo {
-    return this.data.labelInfo
-  }
-
   get moderation(): PostModeration {
     return this.data.moderation
   }
@@ -111,7 +107,7 @@ export class PostThreadItemModel {
           const itemModel = new PostThreadItemModel(this.rootStore, item)
           itemModel._depth = this._depth + 1
           itemModel._showParentReplyLine =
-            itemModel.parentUri !== highlightedPostUri && replies.length === 0
+            itemModel.parentUri !== highlightedPostUri
           if (item.replies?.length) {
             itemModel._showChildReplyLine = true
             itemModel.assignTreeModels(item, highlightedPostUri, false, true)
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index 0a67c783e..85ed13cb4 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {
   AppBskyFeedGetPostThread as GetPostThread,
   AppBskyFeedDefs,
+  PostModeration,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
@@ -12,6 +13,8 @@ import {PostThreadItemModel} from './post-thread-item'
 export class PostThreadModel {
   // state
   isLoading = false
+  isLoadingFromCache = false
+  isFromCache = false
   isRefreshing = false
   hasLoaded = false
   error = ''
@@ -20,7 +23,7 @@ export class PostThreadModel {
   params: GetPostThread.QueryParams
 
   // data
-  thread?: PostThreadItemModel
+  thread?: PostThreadItemModel | null = null
   isBlocked = false
 
   constructor(
@@ -52,7 +55,7 @@ export class PostThreadModel {
   }
 
   get hasContent() {
-    return typeof this.thread !== 'undefined'
+    return !!this.thread
   }
 
   get hasError() {
@@ -82,10 +85,16 @@ export class PostThreadModel {
     if (!this.resolvedUri) {
       await this._resolveUri()
     }
+
     if (this.hasContent) {
       await this.update()
     } else {
-      await this._load()
+      const precache = this.rootStore.posts.cache.get(this.resolvedUri)
+      if (precache) {
+        await this._loadPrecached(precache)
+      } else {
+        await this._load()
+      }
     }
   }
 
@@ -169,6 +178,37 @@ export class PostThreadModel {
     })
   }
 
+  async _loadPrecached(precache: AppBskyFeedDefs.PostView) {
+    // start with the cached version
+    this.isLoadingFromCache = true
+    this.isFromCache = true
+    this._replaceAll({
+      success: true,
+      headers: {},
+      data: {
+        thread: {
+          post: precache,
+        },
+      },
+    })
+    this._xIdle()
+
+    // then update in the background
+    try {
+      const res = await this.rootStore.agent.getPostThread(
+        Object.assign({}, this.params, {uri: this.resolvedUri}),
+      )
+      this._replaceAll(res)
+    } catch (e: any) {
+      console.log(e)
+      this._xIdle(e)
+    } finally {
+      runInAction(() => {
+        this.isLoadingFromCache = false
+      })
+    }
+  }
+
   async _load(isRefreshing = false) {
     if (this.hasLoaded && !isRefreshing) {
       return
@@ -192,7 +232,6 @@ export class PostThreadModel {
       return
     }
     pruneReplies(res.data.thread)
-    sortThread(res.data.thread)
     const thread = new PostThreadItemModel(
       this.rootStore,
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
@@ -202,6 +241,7 @@ export class PostThreadModel {
       res.data.thread as AppBskyFeedDefs.ThreadViewPost,
       thread.uri,
     )
+    sortThread(thread)
     this.thread = thread
   }
 }
@@ -223,24 +263,28 @@ function pruneReplies(post: MaybePost) {
   }
 }
 
-function sortThread(post: MaybePost) {
-  if (post.notFound) {
+type MaybeThreadItem =
+  | PostThreadItemModel
+  | AppBskyFeedDefs.NotFoundPost
+  | AppBskyFeedDefs.BlockedPost
+function sortThread(item: MaybeThreadItem) {
+  if ('notFound' in item) {
     return
   }
-  post = post as AppBskyFeedDefs.ThreadViewPost
-  if (post.replies) {
-    post.replies.sort((a: MaybePost, b: MaybePost) => {
-      post = post as AppBskyFeedDefs.ThreadViewPost
-      if (a.notFound) {
+  item = item as PostThreadItemModel
+  if (item.replies) {
+    item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => {
+      if ('notFound' in a && a.notFound) {
         return 1
       }
-      if (b.notFound) {
+      if ('notFound' in b && b.notFound) {
         return -1
       }
-      a = a as AppBskyFeedDefs.ThreadViewPost
-      b = b as AppBskyFeedDefs.ThreadViewPost
-      const aIsByOp = a.post.author.did === post.post.author.did
-      const bIsByOp = b.post.author.did === post.post.author.did
+      item = item as PostThreadItemModel
+      a = a as PostThreadItemModel
+      b = b as PostThreadItemModel
+      const aIsByOp = a.post.author.did === item.post.author.did
+      const bIsByOp = b.post.author.did === item.post.author.did
       if (aIsByOp && bIsByOp) {
         return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
       } else if (aIsByOp) {
@@ -248,8 +292,31 @@ function sortThread(post: MaybePost) {
       } else if (bIsByOp) {
         return 1 // op's own reply
       }
+      // put moderated content down at the bottom
+      if (modScore(a.moderation) !== modScore(b.moderation)) {
+        return modScore(a.moderation) - modScore(b.moderation)
+      }
       return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
     })
-    post.replies.forEach(reply => sortThread(reply))
+    item.replies.forEach(reply => sortThread(reply))
+  }
+}
+
+function modScore(mod: PostModeration): number {
+  if (mod.content.blur && mod.content.noOverride) {
+    return 5
+  }
+  if (mod.content.blur) {
+    return 4
+  }
+  if (mod.content.alert) {
+    return 3
+  }
+  if (mod.embed.blur && mod.embed.noOverride) {
+    return 2
+  }
+  if (mod.embed.blur) {
+    return 1
   }
+  return 0
 }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 34b2ea28e..26fa6008c 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -6,18 +6,14 @@ import {
   AppBskyActorGetProfile as GetProfile,
   AppBskyActorProfile,
   RichText,
+  moderateProfile,
+  ProfileModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import * as apilib from 'lib/api/index'
 import {cleanError} from 'lib/strings/errors'
 import {FollowState} from '../cache/my-follows'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {ProfileLabelInfo, ProfileModeration} from 'lib/labeling/types'
-import {
-  getProfileModeration,
-  filterAccountLabels,
-  filterProfileLabels,
-} from 'lib/labeling/helpers'
 import {track} from 'lib/analytics/analytics'
 
 export class ProfileViewerModel {
@@ -26,7 +22,8 @@ export class ProfileViewerModel {
   following?: string
   followedBy?: string
   blockedBy?: boolean
-  blocking?: string
+  blocking?: string;
+  [key: string]: unknown
 
   constructor() {
     makeAutoObservable(this)
@@ -53,7 +50,8 @@ export class ProfileModel {
   followsCount: number = 0
   postsCount: number = 0
   labels?: ComAtprotoLabelDefs.Label[] = undefined
-  viewer = new ProfileViewerModel()
+  viewer = new ProfileViewerModel();
+  [key: string]: unknown
 
   // added data
   descriptionRichText?: RichText = new RichText({text: ''})
@@ -85,25 +83,20 @@ export class ProfileModel {
     return this.hasLoaded && !this.hasContent
   }
 
-  get labelInfo(): ProfileLabelInfo {
-    return {
-      accountLabels: filterAccountLabels(this.labels),
-      profileLabels: filterProfileLabels(this.labels),
-      isMuted: this.viewer?.muted || false,
-      isBlocking: !!this.viewer?.blocking || false,
-      isBlockedBy: !!this.viewer?.blockedBy || false,
-    }
-  }
-
   get moderation(): ProfileModeration {
-    return getProfileModeration(this.rootStore, this.labelInfo)
+    return moderateProfile(this, this.rootStore.preferences.moderationOpts)
   }
 
   // public api
   // =
 
   async setup() {
-    await this._load()
+    const precache = await this.rootStore.profiles.cache.get(this.params.actor)
+    if (precache) {
+      await this._loadWithCache(precache)
+    } else {
+      await this._load()
+    }
   }
 
   async refresh() {
@@ -252,7 +245,13 @@ export class ProfileModel {
     this._xLoading(isRefreshing)
     try {
       const res = await this.rootStore.agent.getProfile(this.params)
-      this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
+      this.rootStore.profiles.overwrite(this.params.actor, res)
+      if (res.data.handle) {
+        this.rootStore.handleResolutions.cache.set(
+          res.data.handle,
+          res.data.did,
+        )
+      }
       this._replaceAll(res)
       await this._createRichText()
       this._xIdle()
@@ -261,6 +260,23 @@ export class ProfileModel {
     }
   }
 
+  async _loadWithCache(precache: GetProfile.Response) {
+    // use cached value
+    this._replaceAll(precache)
+    await this._createRichText()
+    this._xIdle()
+
+    // fetch latest
+    try {
+      const res = await this.rootStore.agent.getProfile(this.params)
+      this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation
+      this._replaceAll(res)
+      await this._createRichText()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  }
+
   _replaceAll(res: GetProfile.Response) {
     this.did = res.data.did
     this.handle = res.data.handle
diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts
index 4b25ed4af..580145f65 100644
--- a/src/state/models/discovery/foafs.ts
+++ b/src/state/models/discovery/foafs.ts
@@ -1,6 +1,7 @@
 import {
   AppBskyActorDefs,
   AppBskyGraphGetFollows as GetFollows,
+  moderateProfile,
 } from '@atproto/api'
 import {makeAutoObservable, runInAction} from 'mobx'
 import sampleSize from 'lodash.samplesize'
@@ -52,6 +53,13 @@ export class FoafsModel {
               cursor,
               limit: 100,
             })
+          res.data.follows = res.data.follows.filter(
+            profile =>
+              !moderateProfile(
+                profile,
+                this.rootStore.preferences.moderationOpts,
+              ).account.filter,
+          )
           this.rootStore.me.follows.hydrateProfiles(res.data.follows)
           if (!res.data.cursor) {
             break
@@ -97,11 +105,24 @@ export class FoafsModel {
         const profile = profiles.data.profiles[i]
         const source = this.sources[i]
         if (res.status === 'fulfilled' && profile) {
-          // filter out users already followed by the user or that *is* the user
+          // filter out inappropriate suggestions
           res.value.data.follows = res.value.data.follows.filter(follow => {
-            return (
-              follow.did !== this.rootStore.me.did && !follow.viewer?.following
-            )
+            const viewer = follow.viewer
+            if (viewer) {
+              if (
+                viewer.following ||
+                viewer.muted ||
+                viewer.mutedByList ||
+                viewer.blockedBy ||
+                viewer.blocking
+              ) {
+                return false
+              }
+            }
+            if (follow.did === this.rootStore.me.did) {
+              return false
+            }
+            return true
           })
 
           runInAction(() => {
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index 50faae614..0b3d36952 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -1,5 +1,5 @@
 import {makeAutoObservable, runInAction} from 'mobx'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
 import {bundleAsync} from 'lib/async/bundle'
@@ -69,7 +69,12 @@ export class SuggestedActorsModel {
         limit: 25,
         cursor: this.loadMoreCursor,
       })
-      const {actors, cursor} = res.data
+      let {actors, cursor} = res.data
+      actors = actors.filter(
+        actor =>
+          !moderateProfile(actor, this.rootStore.preferences.moderationOpts)
+            .account.filter,
+      )
       this.rootStore.me.follows.hydrateProfiles(actors)
 
       runInAction(() => {
@@ -80,8 +85,17 @@ export class SuggestedActorsModel {
         this.hasMore = !!cursor
         this.suggestions = this.suggestions.concat(
           actors.filter(actor => {
-            if (actor.viewer?.following) {
-              return false
+            const viewer = actor.viewer
+            if (viewer) {
+              if (
+                viewer.following ||
+                viewer.muted ||
+                viewer.mutedByList ||
+                viewer.blockedBy ||
+                viewer.blocking
+              ) {
+                return false
+              }
             }
             if (actor.did === this.rootStore.me.did) {
               return false
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 05e2ef0db..50a411379 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -8,6 +8,8 @@ import {
   AppBskyFeedLike,
   AppBskyGraphFollow,
   ComAtprotoLabelDefs,
+  moderatePost,
+  moderateProfile,
 } from '@atproto/api'
 import AwaitLock from 'await-lock'
 import chunk from 'lodash.chunk'
@@ -15,24 +17,12 @@ 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
 const MS_1HR = 1e3 * 60 * 60
 const MS_2DAY = MS_1HR * 48
 
-let _idCounter = 0
-
 export const MAX_VISIBLE_NOTIFS = 30
 
 export interface GroupedNotification extends ListNotifications.Notification {
@@ -100,27 +90,19 @@ export class NotificationsFeedItemModel {
     }
   }
 
-  get labelInfo(): PostLabelInfo {
-    const addedInfo = this.additionalPost?.thread?.labelInfo
-    return {
-      postLabels: (this.labels || []).concat(addedInfo?.postLabels || []),
-      accountLabels: filterAccountLabels(this.author.labels).concat(
-        addedInfo?.accountLabels || [],
-      ),
-      profileLabels: filterProfileLabels(this.author.labels).concat(
-        addedInfo?.profileLabels || [],
-      ),
-      isMuted: this.author.viewer?.muted || addedInfo?.isMuted || false,
-      mutedByList: this.author.viewer?.mutedByList || addedInfo?.mutedByList,
-      isBlocking:
-        !!this.author.viewer?.blocking || addedInfo?.isBlocking || false,
-      isBlockedBy:
-        !!this.author.viewer?.blockedBy || addedInfo?.isBlockedBy || false,
+  get shouldFilter(): boolean {
+    if (this.additionalPost?.thread) {
+      const postMod = moderatePost(
+        this.additionalPost.thread.data.post,
+        this.rootStore.preferences.moderationOpts,
+      )
+      return postMod.content.filter || false
     }
-  }
-
-  get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
+    const profileMod = moderateProfile(
+      this.author,
+      this.rootStore.preferences.moderationOpts,
+    )
+    return profileMod.account.filter || false
   }
 
   get numUnreadInGroup(): number {
@@ -259,6 +241,12 @@ export class NotificationsFeedModel {
   loadMoreError = ''
   hasMore = true
   loadMoreCursor?: string
+
+  /**
+   * The last time notifications were seen. Refers to either the
+   * user's machine clock or the value of the `indexedAt` property on their
+   * latest notification, whichever was greater at the time of viewing.
+   */
   lastSync?: Date
 
   // used to linearize async modifications to state
@@ -345,9 +333,6 @@ export class NotificationsFeedModel {
           limit: PAGE_SIZE,
         })
         await this._replaceAll(res)
-        runInAction(() => {
-          this.lastSync = new Date()
-        })
         this._setQueued(undefined)
         this._countUnread()
         this._xIdle()
@@ -503,7 +488,9 @@ export class NotificationsFeedModel {
       const postsRes = await this.rootStore.agent.app.bsky.feed.getPosts({
         uris: [addedUri],
       })
-      notif.setAdditionalData(postsRes.data.posts[0])
+      const post = postsRes.data.posts[0]
+      notif.setAdditionalData(post)
+      this.rootStore.posts.set(post.uri, post)
     }
     const filtered = this._filterNotifications([notif])
     return filtered[0]
@@ -539,9 +526,17 @@ export class NotificationsFeedModel {
   // =
 
   async _replaceAll(res: ListNotifications.Response) {
-    if (res.data.notifications[0]) {
-      this.mostRecentNotificationUri = res.data.notifications[0].uri
+    const latest = res.data.notifications[0]
+
+    if (latest) {
+      const now = new Date()
+      const lastIndexed = new Date(latest.indexedAt)
+      const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed
+
+      this.mostRecentNotificationUri = latest.uri
+      this.lastSync = nowOrLastIndexed
     }
+
     return this._appendAll(res, true)
   }
 
@@ -563,8 +558,7 @@ export class NotificationsFeedModel {
   ): NotificationsFeedItemModel[] {
     return items
       .filter(item => {
-        const hideByLabel =
-          item.moderation.list.behavior === ModerationBehaviorCode.Hide
+        const hideByLabel = item.shouldFilter
         let mutedThread = !!(
           item.reasonSubjectRootUri &&
           this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri)
@@ -588,7 +582,7 @@ export class NotificationsFeedModel {
     for (const item of items) {
       const itemModel = new NotificationsFeedItemModel(
         this.rootStore,
-        `item-${_idCounter++}`,
+        `notification-${item.uri}`,
         item,
       )
       const uri = itemModel.additionalDataUri
@@ -611,6 +605,7 @@ export class NotificationsFeedModel {
         ),
       )
       for (const post of postsChunks.flat()) {
+        this.rootStore.posts.set(post.uri, post)
         const models = addedPostMap.get(post.uri)
         if (models?.length) {
           for (const model of models) {
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index 47039c72a..ae4f29105 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -3,21 +3,13 @@ import {
   AppBskyFeedPost as FeedPost,
   AppBskyFeedDefs,
   RichText,
+  moderatePost,
+  PostModeration,
 } from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {updateDataOptimistically} from 'lib/async/revertible'
-import {PostLabelInfo, PostModeration} from 'lib/labeling/types'
-import {
-  getEmbedLabels,
-  getEmbedMuted,
-  getEmbedMutedByList,
-  getEmbedBlocking,
-  getEmbedBlockedBy,
-  filterAccountLabels,
-  filterProfileLabels,
-  getPostModeration,
-} from 'lib/labeling/helpers'
 import {track} from 'lib/analytics/analytics'
+import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed'
 
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 type ReasonRepost = AppBskyFeedDefs.ReasonRepost
@@ -36,14 +28,15 @@ export class PostsFeedItemModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    reactKey: string,
+    _reactKey: string,
     v: FeedViewPost,
   ) {
-    this._reactKey = reactKey
+    this._reactKey = _reactKey
     this.post = v.post
     if (FeedPost.isRecord(this.post.record)) {
       const valid = FeedPost.validateRecord(this.post.record)
       if (valid.success) {
+        hackAddDeletedEmbed(this.post)
         this.postRecord = this.post.record
         this.richText = new RichText(this.postRecord, {cleanNewlines: true})
       } else {
@@ -86,33 +79,8 @@ export class PostsFeedItemModel {
     return this.rootStore.mutedThreads.uris.has(this.rootUri)
   }
 
-  get labelInfo(): PostLabelInfo {
-    return {
-      postLabels: (this.post.labels || []).concat(
-        getEmbedLabels(this.post.embed),
-      ),
-      accountLabels: filterAccountLabels(this.post.author.labels),
-      profileLabels: filterProfileLabels(this.post.author.labels),
-      isMuted:
-        this.post.author.viewer?.muted ||
-        getEmbedMuted(this.post.embed) ||
-        false,
-      mutedByList:
-        this.post.author.viewer?.mutedByList ||
-        getEmbedMutedByList(this.post.embed),
-      isBlocking:
-        !!this.post.author.viewer?.blocking ||
-        getEmbedBlocking(this.post.embed) ||
-        false,
-      isBlockedBy:
-        !!this.post.author.viewer?.blockedBy ||
-        getEmbedBlockedBy(this.post.embed) ||
-        false,
-    }
-  }
-
   get moderation(): PostModeration {
-    return getPostModeration(this.rootStore, this.labelInfo)
+    return moderatePost(this.post, this.rootStore.preferences.moderationOpts)
   }
 
   copy(v: FeedViewPost) {
diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts
index 239bc5b6a..16e4eef15 100644
--- a/src/state/models/feeds/posts-slice.ts
+++ b/src/state/models/feeds/posts-slice.ts
@@ -1,11 +1,8 @@
 import {makeAutoObservable} from 'mobx'
 import {RootStoreModel} from '../root-store'
 import {FeedViewPostsSlice} from 'lib/api/feed-manip'
-import {mergePostModerations} from 'lib/labeling/helpers'
 import {PostsFeedItemModel} from './post'
 
-let _idCounter = 0
-
 export class PostsFeedSliceModel {
   // ui state
   _reactKey: string = ''
@@ -13,15 +10,15 @@ export class PostsFeedSliceModel {
   // data
   items: PostsFeedItemModel[] = []
 
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    slice: FeedViewPostsSlice,
-  ) {
-    this._reactKey = reactKey
-    for (const item of slice.items) {
+  constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) {
+    this._reactKey = slice._reactKey
+    for (let i = 0; i < slice.items.length; i++) {
       this.items.push(
-        new PostsFeedItemModel(rootStore, `slice-${_idCounter++}`, item),
+        new PostsFeedItemModel(
+          rootStore,
+          `${this._reactKey} - ${i}`,
+          slice.items[i],
+        ),
       )
     }
     makeAutoObservable(this, {rootStore: false})
@@ -55,7 +52,20 @@ export class PostsFeedSliceModel {
   }
 
   get moderation() {
-    return mergePostModerations(this.items.map(item => item.moderation))
+    // prefer the most stringent item
+    const topItem = this.items.find(item => item.moderation.content.filter)
+    if (topItem) {
+      return topItem.moderation
+    }
+    // otherwise just use the first one
+    return this.items[0].moderation
+  }
+
+  shouldFilter(ignoreFilterForDid: string | undefined): boolean {
+    const mods = this.items
+      .filter(item => item.post.author.did !== ignoreFilterForDid)
+      .map(item => item.moderation)
+    return !!mods.find(mod => mod.content.filter)
   }
 
   containsUri(uri: string) {
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 4e6633d38..6facc27ad 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -8,12 +8,11 @@ import AwaitLock from 'await-lock'
 import {bundleAsync} from 'lib/async/bundle'
 import {RootStoreModel} from '../root-store'
 import {cleanError} from 'lib/strings/errors'
-import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
+import {FeedTuner} from 'lib/api/feed-manip'
 import {PostsFeedSliceModel} from './posts-slice'
 import {track} from 'lib/analytics/analytics'
 
 const PAGE_SIZE = 30
-let _idCounter = 0
 
 type QueryParams =
   | GetTimeline.QueryParams
@@ -75,24 +74,6 @@ export class PostsFeedModel {
     return this.hasLoaded && !this.hasContent
   }
 
-  get nonReplyFeed() {
-    if (this.feedType === 'author') {
-      return this.slices.filter(slice => {
-        const params = this.params as GetAuthorFeed.QueryParams
-        const item = slice.rootItem
-        const isRepost =
-          item?.reasonRepost?.by?.handle === params.actor ||
-          item?.reasonRepost?.by?.did === params.actor
-        const allow =
-          !item.postRecord?.reply || // not a reply
-          isRepost // but allow if it's a repost
-        return allow
-      })
-    } else {
-      return this.slices
-    }
-  }
-
   setHasNewLatest(v: boolean) {
     this.hasNewLatest = v
   }
@@ -282,31 +263,26 @@ export class PostsFeedModel {
       return
     }
     const res = await this._getFeed({limit: 1})
-    this.setHasNewLatest(res.data.feed[0]?.post.uri !== this.pollCursor)
+    if (res.data.feed[0]) {
+      const slices = this.tuner.tune(res.data.feed, this.feedTuners)
+      if (slices[0]) {
+        const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0])
+        if (sliceModel.moderation.content.filter) {
+          return
+        }
+        this.setHasNewLatest(sliceModel.uri !== this.pollCursor)
+      }
+    }
   }
 
   /**
-   * Fetches the given post and adds it to the top
-   * Used by the composer to add their new posts
+   * Updates the UI after the user has created a post
    */
-  async addPostToTop(uri: string) {
+  onPostCreated() {
     if (!this.slices.length) {
       return this.refresh()
-    }
-    try {
-      const res = await this.rootStore.agent.app.bsky.feed.getPosts({
-        uris: [uri],
-      })
-      const toPrepend = new PostsFeedSliceModel(
-        this.rootStore,
-        uri,
-        new FeedViewPostsSlice(res.data.posts.map(post => ({post}))),
-      )
-      runInAction(() => {
-        this.slices = [toPrepend].concat(this.slices)
-      })
-    } catch (e) {
-      this.rootStore.log.error('Failed to load post to prepend', {e})
+    } else {
+      this.setHasNewLatest(true)
     }
   }
 
@@ -374,16 +350,15 @@ export class PostsFeedModel {
     this.rootStore.me.follows.hydrateProfiles(
       res.data.feed.map(item => item.post.author),
     )
+    for (const item of res.data.feed) {
+      this.rootStore.posts.fromFeedItem(item)
+    }
 
     const slices = this.tuner.tune(res.data.feed, this.feedTuners)
 
     const toAppend: PostsFeedSliceModel[] = []
     for (const slice of slices) {
-      const sliceModel = new PostsFeedSliceModel(
-        this.rootStore,
-        `item-${_idCounter++}`,
-        slice,
-      )
+      const sliceModel = new PostsFeedSliceModel(this.rootStore, slice)
       toAppend.push(sliceModel)
     }
     runInAction(() => {
@@ -405,6 +380,7 @@ export class PostsFeedModel {
     res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
   ) {
     for (const item of res.data.feed) {
+      this.rootStore.posts.fromFeedItem(item)
       const existingSlice = this.slices.find(slice =>
         slice.containsUri(item.post.uri),
       )
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index 59d79f056..186e61cf6 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -52,6 +52,8 @@ export class MeModel {
     this.mainFeed.clear()
     this.notifications.clear()
     this.follows.clear()
+    this.rootStore.profiles.cache.clear()
+    this.rootStore.posts.cache.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
@@ -104,7 +106,6 @@ export class MeModel {
     this.rootStore.log.debug('MeModel:load', {hasSession: sess.hasSession})
     if (sess.hasSession) {
       this.did = sess.currentSession?.did || ''
-      this.handle = sess.currentSession?.handle || ''
       await this.fetchProfile()
       this.mainFeed.clear()
       /* dont await */ this.mainFeed.setup().catch(e => {
@@ -144,6 +145,7 @@ export class MeModel {
         this.displayName = profile.data.displayName || ''
         this.description = profile.data.description || ''
         this.avatar = profile.data.avatar || ''
+        this.handle = profile.data.handle || ''
         this.followsCount = profile.data.followsCount
         this.followersCount = profile.data.followersCount
       } else {
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index e524c49de..dd5b36170 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -120,8 +120,8 @@ export class ImageModel implements Omit<RNImage, 'size'> {
     }
   }
 
-  async setAltText(altText: string) {
-    this.altText = altText
+  setAltText(altText: string) {
+    this.altText = altText.trim()
   }
 
   // Only compress prior to upload
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index d76ea07c9..6ced8090a 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -12,7 +12,9 @@ import {isObj, hasProp} from 'lib/type-guards'
 import {LogModel} from './log'
 import {SessionModel} from './session'
 import {ShellUiModel} from './ui/shell'
+import {HandleResolutionsCache} from './cache/handle-resolutions'
 import {ProfilesCache} from './cache/profiles-view'
+import {PostsCache} from './cache/posts'
 import {LinkMetasCache} from './cache/link-metas'
 import {NotificationsFeedItemModel} from './feeds/notifications'
 import {MeModel} from './me'
@@ -45,7 +47,9 @@ export class RootStoreModel {
   preferences = new PreferencesModel(this)
   me = new MeModel(this)
   invitedUsers = new InvitedUsers(this)
+  handleResolutions = new HandleResolutionsCache()
   profiles = new ProfilesCache(this)
+  posts = new PostsCache(this)
   linkMetas = new LinkMetasCache(this)
   imageSizes = new ImageSizesCache()
   mutedThreads = new MutedThreads()
diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts
index e1c0b1f71..23668a3dc 100644
--- a/src/state/models/ui/preferences.ts
+++ b/src/state/models/ui/preferences.ts
@@ -1,9 +1,14 @@
 import {makeAutoObservable, runInAction} from 'mobx'
+import {LabelPreference as APILabelPreference} from '@atproto/api'
 import AwaitLock from 'await-lock'
 import isEqual from 'lodash.isequal'
 import {isObj, hasProp} from 'lib/type-guards'
 import {RootStoreModel} from '../root-store'
-import {ComAtprotoLabelDefs, AppBskyActorDefs} from '@atproto/api'
+import {
+  ComAtprotoLabelDefs,
+  AppBskyActorDefs,
+  ModerationOpts,
+} from '@atproto/api'
 import {LabelValGroup} from 'lib/labeling/types'
 import {getLabelValueGroup} from 'lib/labeling/helpers'
 import {
@@ -16,7 +21,8 @@ import {DEFAULT_FEEDS} from 'lib/constants'
 import {isIOS, deviceLocales} from 'platform/detection'
 import {LANGUAGES} from '../../../locale/languages'
 
-export type LabelPreference = 'show' | 'warn' | 'hide'
+// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
+export type LabelPreference = APILabelPreference | 'show'
 const LABEL_GROUPS = [
   'nsfw',
   'nudity',
@@ -408,6 +414,44 @@ export class PreferencesModel {
     return res
   }
 
+  get moderationOpts(): ModerationOpts {
+    return {
+      userDid: this.rootStore.session.currentSession?.did || '',
+      adultContentEnabled: this.adultContentEnabled,
+      labels: {
+        // TEMP translate old settings until this UI can be migrated -prf
+        porn: tempfixLabelPref(this.contentLabels.nsfw),
+        sexual: tempfixLabelPref(this.contentLabels.suggestive),
+        nudity: tempfixLabelPref(this.contentLabels.nudity),
+        nsfl: tempfixLabelPref(this.contentLabels.gore),
+        corpse: tempfixLabelPref(this.contentLabels.gore),
+        gore: tempfixLabelPref(this.contentLabels.gore),
+        torture: tempfixLabelPref(this.contentLabels.gore),
+        'self-harm': tempfixLabelPref(this.contentLabels.gore),
+        'intolerant-race': tempfixLabelPref(this.contentLabels.hate),
+        'intolerant-gender': tempfixLabelPref(this.contentLabels.hate),
+        'intolerant-sexual-orientation': tempfixLabelPref(
+          this.contentLabels.hate,
+        ),
+        'intolerant-religion': tempfixLabelPref(this.contentLabels.hate),
+        intolerant: tempfixLabelPref(this.contentLabels.hate),
+        'icon-intolerant': tempfixLabelPref(this.contentLabels.hate),
+        spam: tempfixLabelPref(this.contentLabels.spam),
+        impersonation: tempfixLabelPref(this.contentLabels.impersonation),
+        scam: 'warn',
+      },
+      labelers: [
+        {
+          labeler: {
+            did: '',
+            displayName: 'Bluesky Social',
+          },
+          labels: {},
+        },
+      ],
+    }
+  }
+
   async setSavedFeeds(saved: string[], pinned: string[]) {
     const oldSaved = this.savedFeeds
     const oldPinned = this.pinnedFeeds
@@ -485,3 +529,11 @@ export class PreferencesModel {
     this.requireAltTextEnabled = !this.requireAltTextEnabled
   }
 }
+
+// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf
+function tempfixLabelPref(pref: LabelPreference): APILabelPreference {
+  if (pref === 'show') {
+    return 'ignore'
+  }
+  return pref
+}
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index a0249d768..9dae09ec5 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -6,8 +6,9 @@ import {ActorFeedsModel} from '../lists/actor-feeds'
 import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
-  Posts = 'Posts',
+  PostsNoReplies = 'Posts',
   PostsWithReplies = 'Posts & replies',
+  PostsWithMedia = 'Media',
   CustomAlgorithms = 'Feeds',
   Lists = 'Lists',
 }
@@ -46,6 +47,7 @@ export class ProfileUiModel {
     this.feed = new PostsFeedModel(rootStore, 'author', {
       actor: params.user,
       limit: 10,
+      filter: 'posts_no_replies',
     })
     this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
     this.lists = new ListsListModel(rootStore, params.user)
@@ -53,8 +55,9 @@ export class ProfileUiModel {
 
   get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
-      this.selectedView === Sections.Posts ||
-      this.selectedView === Sections.PostsWithReplies
+      this.selectedView === Sections.PostsNoReplies ||
+      this.selectedView === Sections.PostsWithReplies ||
+      this.selectedView === Sections.PostsWithMedia
     ) {
       return this.feed
     } else if (this.selectedView === Sections.Lists) {
@@ -76,7 +79,11 @@ export class ProfileUiModel {
   }
 
   get selectorItems() {
-    const items = [Sections.Posts, Sections.PostsWithReplies]
+    const items = [
+      Sections.PostsNoReplies,
+      Sections.PostsWithReplies,
+      Sections.PostsWithMedia,
+    ]
     if (this.algos.hasLoaded && !this.algos.isEmpty) {
       items.push(Sections.CustomAlgorithms)
     }
@@ -90,7 +97,7 @@ export class ProfileUiModel {
     // If, for whatever reason, the selected view index is not available, default back to posts
     // This can happen when the user was focused on a view but performed an action that caused
     // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y)
-    return this.selectorItems[this.selectedViewIndex] || Sections.Posts
+    return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies
   }
 
   get uiItems() {
@@ -107,26 +114,25 @@ export class ProfileUiModel {
         },
       ])
     } else {
-      // not loading, no error, show content
       if (
-        this.selectedView === Sections.Posts ||
+        this.selectedView === Sections.PostsNoReplies ||
         this.selectedView === Sections.PostsWithReplies ||
-        this.selectedView === Sections.CustomAlgorithms
+        this.selectedView === Sections.PostsWithMedia
       ) {
         if (this.feed.hasContent) {
-          if (this.selectedView === Sections.CustomAlgorithms) {
-            arr = this.algos.feeds
-          } else if (this.selectedView === Sections.Posts) {
-            arr = this.feed.nonReplyFeed
-          } else {
-            arr = this.feed.slices.slice()
-          }
+          arr = this.feed.slices.slice()
           if (!this.feed.hasMore) {
             arr = arr.concat([ProfileUiModel.END_ITEM])
           }
         } else if (this.feed.isEmpty) {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
+      } else if (this.selectedView === Sections.CustomAlgorithms) {
+        if (this.algos.hasContent) {
+          arr = this.algos.feeds
+        } else if (this.algos.isEmpty) {
+          arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
+        }
       } else if (this.selectedView === Sections.Lists) {
         if (this.lists.hasContent) {
           arr = this.lists.lists
@@ -143,8 +149,9 @@ export class ProfileUiModel {
 
   get showLoadingMoreFooter() {
     if (
-      this.selectedView === Sections.Posts ||
-      this.selectedView === Sections.PostsWithReplies
+      this.selectedView === Sections.PostsNoReplies ||
+      this.selectedView === Sections.PostsWithReplies ||
+      this.selectedView === Sections.PostsWithMedia
     ) {
       return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading
     } else if (this.selectedView === Sections.Lists) {
@@ -157,7 +164,27 @@ export class ProfileUiModel {
   // =
 
   setSelectedViewIndex(index: number) {
+    // ViewSelector fires onSelectView on mount
+    if (index === this.selectedViewIndex) return
+
     this.selectedViewIndex = index
+
+    let filter = 'posts_no_replies'
+    if (this.selectedView === Sections.PostsWithReplies) {
+      filter = 'posts_with_replies'
+    } else if (this.selectedView === Sections.PostsWithMedia) {
+      filter = 'posts_with_media'
+    }
+
+    this.feed = new PostsFeedModel(this.rootStore, 'author', {
+      actor: this.params.user,
+      limit: 10,
+      filter,
+    })
+
+    if (this.currentView instanceof PostsFeedModel) {
+      this.feed.setup()
+    }
   }
 
   async setup() {
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index e33a34acf..92d028c79 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -1,4 +1,4 @@
-import {AppBskyEmbedRecord} from '@atproto/api'
+import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {makeAutoObservable, runInAction} from 'mobx'
 import {ProfileModel} from '../content/profile'
@@ -42,16 +42,21 @@ export interface ServerInputModal {
   onSelect: (url: string) => void
 }
 
-export interface ReportPostModal {
-  name: 'report-post'
-  postUri: string
-  postCid: string
+export interface ModerationDetailsModal {
+  name: 'moderation-details'
+  context: 'account' | 'content'
+  moderation: ModerationUI
 }
 
-export interface ReportAccountModal {
-  name: 'report-account'
-  did: string
-}
+export type ReportModal = {
+  name: 'report'
+} & (
+  | {
+      uri: string
+      cid: string
+    }
+  | {did: string}
+)
 
 export interface CreateOrEditMuteListModal {
   name: 'create-or-edit-mute-list'
@@ -94,6 +99,13 @@ export interface RepostModal {
   isReposted: boolean
 }
 
+export interface SelfLabelModal {
+  name: 'self-label'
+  labels: string[]
+  hasMedia: boolean
+  onChange: (labels: string[]) => void
+}
+
 export interface ChangeHandleModal {
   name: 'change-handle'
   onChanged: () => void
@@ -146,8 +158,8 @@ export type Modal =
   | PreferencesHomeFeed
 
   // Moderation
-  | ReportAccountModal
-  | ReportPostModal
+  | ModerationDetailsModal
+  | ReportModal
   | CreateOrEditMuteListModal
   | ListAddRemoveUserModal
 
@@ -157,6 +169,7 @@ export type Modal =
   | EditImageModal
   | ServerInputModal
   | RepostModal
+  | SelfLabelModal
 
   // Bluesky access
   | WaitlistModal
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0fae996ff..ecfef3ecd 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -32,6 +32,8 @@ import {s, colors, gradients} from 'lib/styles'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {cleanError} from 'lib/strings/errors'
+import {shortenLinks} from 'lib/strings/rich-text-manip'
+import {toShortUrl} from 'lib/strings/url-helpers'
 import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
 import {OpenCameraBtn} from './photos/OpenCameraBtn'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -41,6 +43,7 @@ import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
+import {LabelsBtn} from './labels/LabelsBtn'
 import {SelectLangBtn} from './select-language/SelectLangBtn'
 
 type Props = ComposerOpts & {
@@ -62,11 +65,14 @@ export const ComposePost = observer(function ComposePost({
   const [processingState, setProcessingState] = useState('')
   const [error, setError] = useState('')
   const [richtext, setRichText] = useState(new RichText({text: ''}))
-  const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
+  const graphemeLength = useMemo(() => {
+    return shortenLinks(richtext).graphemeLength
+  }, [richtext])
   const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
     initQuote,
   )
   const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+  const [labels, setLabels] = useState<string[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
   const gallery = useMemo(() => new GalleryModel(store), [store])
 
@@ -145,76 +151,59 @@ export const ComposePost = observer(function ComposePost({
     [gallery, track],
   )
 
-  const onPressPublish = useCallback(
-    async (rt: RichText) => {
-      if (isProcessing || rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
-        return
-      }
-      if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
-        return
-      }
+  const onPressPublish = async () => {
+    if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
+      return
+    }
+    if (store.preferences.requireAltTextEnabled && gallery.needsAltText) {
+      return
+    }
 
-      setError('')
+    setError('')
 
-      if (rt.text.trim().length === 0 && gallery.isEmpty) {
-        setError('Did you want to say anything?')
-        return
-      }
+    if (richtext.text.trim().length === 0 && gallery.isEmpty) {
+      setError('Did you want to say anything?')
+      return
+    }
 
-      setIsProcessing(true)
+    setIsProcessing(true)
 
-      let createdPost
-      try {
-        createdPost = await apilib.post(store, {
-          rawText: rt.text,
-          replyTo: replyTo?.uri,
-          images: gallery.images,
-          quote: quote,
-          extLink: extLink,
-          onStateChange: setProcessingState,
-          knownHandles: autocompleteView.knownHandles,
-          langs: store.preferences.postLanguages,
-        })
-      } catch (e: any) {
-        if (extLink) {
-          setExtLink({
-            ...extLink,
-            isLoading: true,
-            localThumb: undefined,
-          } as apilib.ExternalEmbedDraft)
-        }
-        setError(cleanError(e.message))
-        setIsProcessing(false)
-        return
-      } finally {
-        track('Create Post', {
-          imageCount: gallery.size,
-        })
-        if (replyTo && replyTo.uri) track('Post:Reply')
-      }
-      if (!replyTo) {
-        await store.me.mainFeed.addPostToTop(createdPost.uri)
+    try {
+      await apilib.post(store, {
+        rawText: richtext.text,
+        replyTo: replyTo?.uri,
+        images: gallery.images,
+        quote,
+        extLink,
+        labels,
+        onStateChange: setProcessingState,
+        knownHandles: autocompleteView.knownHandles,
+        langs: store.preferences.postLanguages,
+      })
+    } catch (e: any) {
+      if (extLink) {
+        setExtLink({
+          ...extLink,
+          isLoading: true,
+          localThumb: undefined,
+        } as apilib.ExternalEmbedDraft)
       }
-      onPost?.()
-      onClose()
-      Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
-    },
-    [
-      isProcessing,
-      setError,
-      setIsProcessing,
-      replyTo,
-      autocompleteView.knownHandles,
-      extLink,
-      onClose,
-      onPost,
-      quote,
-      setExtLink,
-      store,
-      track,
-      gallery,
-    ],
-  )
+      setError(cleanError(e.message))
+      setIsProcessing(false)
+      return
+    } finally {
+      track('Create Post', {
+        imageCount: gallery.size,
+      })
+      if (replyTo && replyTo.uri) track('Post:Reply')
+    }
+    if (!replyTo) {
+      store.me.mainFeed.onPostCreated()
+    }
+    onPost?.()
+    onClose()
+    Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
+  }
 
   const canPost = useMemo(
     () =>
@@ -229,6 +218,7 @@ export const ComposePost = observer(function ComposePost({
   const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
 
   const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
+  const hasMedia = gallery.size > 0 || Boolean(extLink)
 
   return (
     <KeyboardAvoidingView
@@ -247,6 +237,7 @@ export const ComposePost = observer(function ComposePost({
             <Text style={[pal.link, s.f18]}>Cancel</Text>
           </TouchableOpacity>
           <View style={s.flex1} />
+          <LabelsBtn labels={labels} onChange={setLabels} hasMedia={hasMedia} />
           {isProcessing ? (
             <View style={styles.postBtn}>
               <ActivityIndicator />
@@ -254,9 +245,7 @@ export const ComposePost = observer(function ComposePost({
           ) : canPost ? (
             <TouchableOpacity
               testID="composerPublishBtn"
-              onPress={() => {
-                onPressPublish(richtext)
-              }}
+              onPress={onPressPublish}
               accessibilityRole="button"
               accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'}
               accessibilityHint={
@@ -366,20 +355,23 @@ export const ComposePost = observer(function ComposePost({
         </ScrollView>
         {!extLink && suggestedLinks.size > 0 ? (
           <View style={s.mb5}>
-            {Array.from(suggestedLinks).map(url => (
-              <TouchableOpacity
-                key={`suggested-${url}`}
-                testID="addLinkCardBtn"
-                style={[pal.borderDark, styles.addExtLinkBtn]}
-                onPress={() => onPressAddLinkCard(url)}
-                accessibilityRole="button"
-                accessibilityLabel="Add link card"
-                accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
-                <Text style={pal.text}>
-                  Add link card: <Text style={pal.link}>{url}</Text>
-                </Text>
-              </TouchableOpacity>
-            ))}
+            {Array.from(suggestedLinks)
+              .slice(0, 3)
+              .map(url => (
+                <TouchableOpacity
+                  key={`suggested-${url}`}
+                  testID="addLinkCardBtn"
+                  style={[pal.borderDark, styles.addExtLinkBtn]}
+                  onPress={() => onPressAddLinkCard(url)}
+                  accessibilityRole="button"
+                  accessibilityLabel="Add link card"
+                  accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
+                  <Text style={pal.text}>
+                    Add link card:{' '}
+                    <Text style={pal.link}>{toShortUrl(url)}</Text>
+                  </Text>
+                </TouchableOpacity>
+              ))}
           </View>
         ) : null}
         <View style={[pal.border, styles.bottomBar]}>
@@ -408,7 +400,7 @@ const styles = StyleSheet.create({
     flexDirection: 'row',
     alignItems: 'center',
     paddingTop: isDesktopWeb ? 10 : undefined,
-    paddingBottom: 10,
+    paddingBottom: isDesktopWeb ? 10 : 4,
     paddingHorizontal: 20,
     height: 55,
   },
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
new file mode 100644
index 000000000..96908d47f
--- /dev/null
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {Keyboard, StyleSheet} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Button} from 'view/com/util/forms/Button'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {ShieldExclamation} from 'lib/icons'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
+import {isNative} from 'platform/detection'
+
+export const LabelsBtn = observer(function LabelsBtn({
+  labels,
+  hasMedia,
+  onChange,
+}: {
+  labels: string[]
+  hasMedia: boolean
+  onChange: (v: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  return (
+    <Button
+      type="default-light"
+      testID="labelsBtn"
+      style={[styles.button, !hasMedia && styles.dimmed]}
+      accessibilityLabel="Content warnings"
+      accessibilityHint=""
+      onPress={() => {
+        if (isNative) {
+          if (Keyboard.isVisible()) {
+            Keyboard.dismiss()
+          }
+        }
+        store.shell.openModal({name: 'self-label', labels, hasMedia, onChange})
+      }}>
+      <ShieldExclamation style={pal.link} size={26} />
+      {labels.length > 0 ? (
+        <FontAwesomeIcon
+          icon="check"
+          size={16}
+          style={pal.link as FontAwesomeIconStyle}
+        />
+      ) : null}
+    </Button>
+  )
+})
+
+const styles = StyleSheet.create({
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 14,
+    marginRight: 4,
+  },
+  dimmed: {
+    opacity: 0.4,
+  },
+  label: {
+    maxWidth: 100,
+  },
+})
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 245c17b9c..f64880e15 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {RichText} from '@atproto/api'
+import EventEmitter from 'eventemitter3'
 import {useEditor, EditorContent, JSONContent} from '@tiptap/react'
 import {Document} from '@tiptap/extension-document'
 import History from '@tiptap/extension-history'
@@ -53,6 +54,22 @@ export const TextInput = React.forwardRef(
       'ProseMirror-dark',
     )
 
+    // we use a memoized emitter to propagate events out of tiptap
+    // without triggering re-runs of the useEditor hook
+    const emitter = React.useMemo(() => new EventEmitter(), [])
+    React.useEffect(() => {
+      emitter.addListener('publish', onPressPublish)
+      return () => {
+        emitter.removeListener('publish', onPressPublish)
+      }
+    }, [emitter, onPressPublish])
+    React.useEffect(() => {
+      emitter.addListener('photo-pasted', onPhotoPasted)
+      return () => {
+        emitter.removeListener('photo-pasted', onPhotoPasted)
+      }
+    }, [emitter, onPhotoPasted])
+
     const editor = useEditor(
       {
         extensions: [
@@ -60,6 +77,7 @@ export const TextInput = React.forwardRef(
           Link.configure({
             protocols: ['http', 'https'],
             autolink: true,
+            linkOnPaste: false,
           }),
           Mention.configure({
             HTMLAttributes: {
@@ -86,16 +104,13 @@ export const TextInput = React.forwardRef(
               return
             }
 
-            getImageFromUri(items, onPhotoPasted)
+            getImageFromUri(items, (uri: string) => {
+              emitter.emit('photo-pasted', uri)
+            })
           },
           handleKeyDown: (_, event) => {
             if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {
-              // Workaround relying on previous state from `setRichText` to
-              // get the updated text content during editor initialization
-              setRichText((state: RichText) => {
-                onPressPublish(state)
-                return state
-              })
+              emitter.emit('publish')
             }
           },
         },
@@ -107,6 +122,7 @@ export const TextInput = React.forwardRef(
           const json = editorProp.getJSON()
 
           const newRt = new RichText({text: editorJsonToText(json).trim()})
+          newRt.detectFacetsWithoutResolution()
           setRichText(newRt)
 
           const newSuggestedLinks = new Set(editorJsonToLinks(json))
@@ -115,7 +131,7 @@ export const TextInput = React.forwardRef(
           }
         },
       },
-      [modeClass],
+      [modeClass, emitter],
     )
 
     React.useImperativeHandle(ref, () => ({
diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx
index 79f1dd74d..264c2d982 100644
--- a/src/view/com/feeds/CustomFeed.tsx
+++ b/src/view/com/feeds/CustomFeed.tsx
@@ -69,6 +69,7 @@ export const CustomFeed = observer(
 
     return (
       <TouchableOpacity
+        testID={`feed-${item.displayName}`}
         accessibilityRole="button"
         style={[styles.container, pal.border, style]}
         onPress={() => {
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
index b900f9afe..f5e858209 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx
@@ -17,6 +17,7 @@ import {
   NativeSyntheticEvent,
   NativeMethodsMixin,
 } from 'react-native'
+import {Image} from 'expo-image'
 
 import useImageDimensions from '../../hooks/useImageDimensions'
 import usePanResponder from '../../hooks/usePanResponder'
@@ -41,6 +42,8 @@ type Props = {
   doubleTapToZoomEnabled?: boolean
 }
 
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+
 const ImageItem = ({
   imageSrc,
   onZoom,
@@ -128,7 +131,7 @@ const ImageItem = ({
         onScroll,
         onScrollEndDrag,
       })}>
-      <Animated.Image
+      <AnimatedImage
         {...panHandlers}
         source={imageSrc}
         style={imageStylesWithOpacity}
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
index ebf0b1d28..a6b98009a 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx
@@ -18,6 +18,7 @@ import {
   NativeSyntheticEvent,
   TouchableWithoutFeedback,
 } from 'react-native'
+import {Image} from 'expo-image'
 
 import useDoubleTapToZoom from '../../hooks/useDoubleTapToZoom'
 import useImageDimensions from '../../hooks/useImageDimensions'
@@ -42,6 +43,8 @@ type Props = {
   doubleTapToZoomEnabled?: boolean
 }
 
+const AnimatedImage = Animated.createAnimatedComponent(Image)
+
 const ImageItem = ({
   imageSrc,
   onZoom,
@@ -131,7 +134,7 @@ const ImageItem = ({
           accessibilityRole="image"
           accessibilityLabel={imageSrc.alt}
           accessibilityHint="">
-          <Animated.Image
+          <AnimatedImage
             source={imageSrc}
             style={imageStylesWithOpacity}
             onLoad={() => setLoaded(true)}
diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx
index ee5a2afcb..353338198 100644
--- a/src/view/com/lists/ListActions.tsx
+++ b/src/view/com/lists/ListActions.tsx
@@ -11,6 +11,7 @@ export const ListActions = ({
   isOwner,
   onPressDeleteList,
   onPressShareList,
+  onPressReportList,
   reversed = false, // Default value of reversed is false
 }: {
   isOwner: boolean
@@ -19,6 +20,7 @@ export const ListActions = ({
   onPressEditList?: () => void
   onPressDeleteList?: () => void
   onPressShareList?: () => void
+  onPressReportList?: () => void
   reversed?: boolean // New optional prop
 }) => {
   const pal = usePalette('default')
@@ -64,6 +66,17 @@ export const ListActions = ({
       onPress={onPressShareList}>
       <FontAwesomeIcon icon={'share'} style={[pal.text]} />
     </Button>,
+    !isOwner && (
+      <Button
+        key="reportListBtn"
+        testID="reportListBtn"
+        type="default"
+        accessibilityLabel="Report list"
+        accessibilityHint=""
+        onPress={onPressReportList}>
+        <FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} />
+      </Button>
+    ),
   ]
 
   // If reversed is true, reverse the array to reverse the order of the buttons
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx
index 188518ea5..7f2173d78 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListItems.tsx
@@ -45,6 +45,7 @@ export const ListItems = observer(
     onPressEditList,
     onPressDeleteList,
     onPressShareList,
+    onPressReportList,
     renderEmptyState,
     testID,
     headerOffset = 0,
@@ -57,6 +58,7 @@ export const ListItems = observer(
     onPressEditList: () => void
     onPressDeleteList: () => void
     onPressShareList: () => void
+    onPressReportList: () => void
     renderEmptyState?: () => JSX.Element
     testID?: string
     headerOffset?: number
@@ -169,6 +171,7 @@ export const ListItems = observer(
               onPressEditList={onPressEditList}
               onPressDeleteList={onPressDeleteList}
               onPressShareList={onPressShareList}
+              onPressReportList={onPressReportList}
             />
           ) : null
         } else if (item === ERROR_ITEM) {
@@ -208,6 +211,7 @@ export const ListItems = observer(
         onPressEditList,
         onPressDeleteList,
         onPressShareList,
+        onPressReportList,
         onPressTryAgain,
         onPressRetryLoadMore,
       ],
@@ -267,6 +271,7 @@ const ListHeader = observer(
     onPressEditList,
     onPressDeleteList,
     onPressShareList,
+    onPressReportList,
   }: {
     list: AppBskyGraphDefs.ListView
     isOwner: boolean
@@ -274,6 +279,7 @@ const ListHeader = observer(
     onPressEditList: () => void
     onPressDeleteList: () => void
     onPressShareList: () => void
+    onPressReportList: () => void
   }) => {
     const pal = usePalette('default')
     const store = useStores()
@@ -300,6 +306,7 @@ const ListHeader = observer(
                   <TextLink
                     text={sanitizeHandle(list.creator.handle, '@')}
                     href={makeProfileLink(list.creator)}
+                    style={pal.textLight}
                   />
                 )}
               </Text>
@@ -319,6 +326,7 @@ const ListHeader = observer(
                 onPressEditList={onPressEditList}
                 onToggleSubscribed={onToggleSubscribed}
                 onPressShareList={onPressShareList}
+                onPressReportList={onPressReportList}
               />
             )}
           </View>
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
index 2b6f74c2b..fb07ee0b8 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/ListsList.tsx
@@ -1,6 +1,5 @@
 import React, {MutableRefObject} from 'react'
 import {
-  ActivityIndicator,
   RefreshControl,
   StyleProp,
   StyleSheet,
@@ -166,18 +165,6 @@ export const ListsList = observer(
       ],
     )
 
-    const Footer = React.useCallback(
-      () =>
-        listsList.isLoading ? (
-          <View style={styles.feedFooter}>
-            <ActivityIndicator />
-          </View>
-        ) : (
-          <View />
-        ),
-      [listsList],
-    )
-
     return (
       <View testID={testID} style={style}>
         {data.length > 0 && (
@@ -187,7 +174,6 @@ export const ListsList = observer(
             data={data}
             keyExtractor={item => item._reactKey}
             renderItem={renderItemInner}
-            ListFooterComponent={Footer}
             refreshControl={
               <RefreshControl
                 refreshing={isRefreshing}
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index a6010906c..0b9707622 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -493,7 +493,9 @@ function CustomHandleForm({
           <ActivityIndicator color="white" />
         ) : (
           <Text type="xl-medium" style={[s.white, s.textCenter]}>
-            {canSave ? `Update to ${handle}` : 'Verify DNS Record'}
+            {canSave
+              ? `Update to ${handle}`
+              : `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`}
           </Text>
         )}
       </Button>
diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx
index b3fe9dd3f..d46579f09 100644
--- a/src/view/com/modals/InviteCodes.tsx
+++ b/src/view/com/modals/InviteCodes.tsx
@@ -53,11 +53,7 @@ export function Component({}: {}) {
         Invite a Friend
       </Text>
       <Text type="lg" style={[styles.description, pal.text]}>
-        Send these invites to your friends so they can create an account. Each
-        code works once!
-      </Text>
-      <Text type="sm" style={[styles.description, pal.textLight]}>
-        (You'll receive one invite code every two weeks.)
+        Each code works once. You'll receive more invite codes periodically.
       </Text>
       <ScrollView style={[styles.scrollContainer, pal.border]}>
         {store.me.invites.map((invite, i) => (
diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/ListAddRemoveUser.tsx
index 0f001f911..bfb7e4dc0 100644
--- a/src/view/com/modals/ListAddRemoveUser.tsx
+++ b/src/view/com/modals/ListAddRemoveUser.tsx
@@ -1,6 +1,6 @@
 import React, {useCallback} from 'react'
 import {observer} from 'mobx-react-lite'
-import {Pressable, StyleSheet, View} from 'react-native'
+import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native'
 import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
 import {
   FontAwesomeIcon,
@@ -42,6 +42,7 @@ export const Component = observer(
       string[]
     >([])
     const [selected, setSelected] = React.useState<string[]>([])
+    const [membershipsLoaded, setMembershipsLoaded] = React.useState(false)
 
     const listsList: ListsListModel = React.useMemo(
       () => new ListsListModel(store, store.me.did),
@@ -58,12 +59,13 @@ export const Component = observer(
           const ids = memberships.memberships.map(m => m.value.list)
           setOriginalSelections(ids)
           setSelected(ids)
+          setMembershipsLoaded(true)
         },
         err => {
           store.log.error('Failed to fetch memberships', {err})
         },
       )
-    }, [memberships, listsList, store, setSelected])
+    }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
 
     const onPressCancel = useCallback(() => {
       store.shell.closeModal()
@@ -107,11 +109,16 @@ export const Component = observer(
         return (
           <Pressable
             testID={`toggleBtn-${list.name}`}
-            style={[styles.listItem, pal.border]}
+            style={[
+              styles.listItem,
+              pal.border,
+              {opacity: membershipsLoaded ? 1 : 0.5},
+            ]}
             accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${
               list.name
             }`}
             accessibilityHint=""
+            disabled={!membershipsLoaded}
             onPress={() => onToggleSelected(list.uri)}>
             <View style={styles.listItemAvi}>
               <UserAvatar size={40} avatar={list.avatar} />
@@ -132,23 +139,33 @@ export const Component = observer(
                   : sanitizeHandle(list.creator.handle, '@')}
               </Text>
             </View>
-            <View
-              style={
-                isSelected
-                  ? [styles.checkbox, palPrimary.border, palPrimary.view]
-                  : [styles.checkbox, pal.borderDark]
-              }>
-              {isSelected && (
-                <FontAwesomeIcon
-                  icon="check"
-                  style={palInverted.text as FontAwesomeIconStyle}
-                />
-              )}
-            </View>
+            {membershipsLoaded && (
+              <View
+                style={
+                  isSelected
+                    ? [styles.checkbox, palPrimary.border, palPrimary.view]
+                    : [styles.checkbox, pal.borderDark]
+                }>
+                {isSelected && (
+                  <FontAwesomeIcon
+                    icon="check"
+                    style={palInverted.text as FontAwesomeIconStyle}
+                  />
+                )}
+              </View>
+            )}
           </Pressable>
         )
       },
-      [pal, palPrimary, palInverted, onToggleSelected, selected, store.me.did],
+      [
+        pal,
+        palPrimary,
+        palInverted,
+        onToggleSelected,
+        selected,
+        store.me.did,
+        membershipsLoaded,
+      ],
     )
 
     const renderEmptyState = React.useCallback(() => {
@@ -200,6 +217,12 @@ export const Component = observer(
               label="Save Changes"
             />
           )}
+
+          {(listsList.isLoading || !membershipsLoaded) && (
+            <View style={styles.loadingContainer}>
+              <ActivityIndicator />
+            </View>
+          )}
         </View>
       </View>
     )
@@ -221,6 +244,7 @@ const styles = StyleSheet.create({
     borderTopWidth: 1,
   },
   btns: {
+    position: 'relative',
     flexDirection: 'row',
     alignItems: 'center',
     justifyContent: 'center',
@@ -263,4 +287,11 @@ const styles = StyleSheet.create({
     borderRadius: 6,
     marginRight: 8,
   },
+  loadingContainer: {
+    position: 'absolute',
+    top: 10,
+    right: 0,
+    bottom: 0,
+    justifyContent: 'center',
+  },
 })
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 525df7ba1..efd06412d 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -6,18 +6,20 @@ import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
 import {usePalette} from 'lib/hooks/usePalette'
+import {navigate} from '../../../Navigation'
+import once from 'lodash.once'
 
 import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
 import * as ServerInputModal from './ServerInput'
-import * as ReportPostModal from './report/ReportPost'
 import * as RepostModal from './Repost'
+import * as SelfLabelModal from './SelfLabel'
 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
 import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
-import * as ReportAccountModal from './report/ReportAccount'
+import * as ReportModal from './report/Modal'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
@@ -28,6 +30,7 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -35,9 +38,25 @@ export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
   const bottomSheetRef = useRef<BottomSheet>(null)
   const pal = usePalette('default')
+
+  const activeModal =
+    store.shell.activeModals[store.shell.activeModals.length - 1]
+
+  const navigateOnce = once(navigate)
+
+  const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => {
+    if (activeModal?.name === 'profile-preview' && toIndex === 1) {
+      // begin loading the profile screen behind the scenes
+      navigateOnce('Profile', {name: activeModal.did})
+    }
+  }
   const onBottomSheetChange = (snapPoint: number) => {
     if (snapPoint === -1) {
       store.shell.closeModal()
+    } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) {
+      // ensure we navigate to Profile and close the modal
+      navigateOnce('Profile', {name: activeModal.did})
+      store.shell.closeModal()
     }
   }
   const onClose = () => {
@@ -45,9 +64,6 @@ export const ModalsContainer = observer(function ModalsContainer() {
     store.shell.closeModal()
   }
 
-  const activeModal =
-    store.shell.activeModals[store.shell.activeModals.length - 1]
-
   useEffect(() => {
     if (store.shell.isModalActive) {
       bottomSheetRef.current?.expand()
@@ -70,12 +86,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'server-input') {
     snapPoints = ServerInputModal.snapPoints
     element = <ServerInputModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'report-post') {
-    snapPoints = ReportPostModal.snapPoints
-    element = <ReportPostModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'report-account') {
-    snapPoints = ReportAccountModal.snapPoints
-    element = <ReportAccountModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'report') {
+    snapPoints = ReportModal.snapPoints
+    element = <ReportModal.Component {...activeModal} />
   } else if (activeModal?.name === 'create-or-edit-mute-list') {
     snapPoints = CreateOrEditMuteListModal.snapPoints
     element = <CreateOrEditMuteListModal.Component {...activeModal} />
@@ -88,6 +101,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'repost') {
     snapPoints = RepostModal.snapPoints
     element = <RepostModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'self-label') {
+    snapPoints = SelfLabelModal.snapPoints
+    element = <SelfLabelModal.Component {...activeModal} />
   } else if (activeModal?.name === 'alt-text-image') {
     snapPoints = AltImageModal.snapPoints
     element = <AltImageModal.Component {...activeModal} />
@@ -121,6 +137,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'onboarding') {
     snapPoints = OnboardingModal.snapPoints
     element = <OnboardingModal.Component />
+  } else if (activeModal?.name === 'moderation-details') {
+    snapPoints = ModerationDetailsModal.snapPoints
+    element = <ModerationDetailsModal.Component {...activeModal} />
   } else {
     return null
   }
@@ -146,6 +165,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
       }
       handleIndicatorStyle={{backgroundColor: pal.text.color}}
       handleStyle={[styles.handle, pal.view]}
+      onAnimate={onBottomSheetAnimate}
       onChange={onBottomSheetChange}>
       {element}
     </BottomSheet>
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 39cdbd868..0e28b1618 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -10,12 +10,12 @@ import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ProfilePreviewModal from './ProfilePreview'
 import * as ServerInputModal from './ServerInput'
-import * as ReportPostModal from './report/ReportPost'
-import * as ReportAccountModal from './report/ReportAccount'
+import * as ReportModal from './report/Modal'
 import * as CreateOrEditMuteListModal from './CreateOrEditMuteList'
 import * as ListAddRemoveUserModal from './ListAddRemoveUser'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
+import * as SelfLabelModal from './SelfLabel'
 import * as CropImageModal from './crop-image/CropImage.web'
 import * as AltTextImageModal from './AltImage'
 import * as EditImageModal from './EditImage'
@@ -27,6 +27,7 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as OnboardingModal from './OnboardingModal'
+import * as ModerationDetailsModal from './ModerationDetails'
 
 import * as PreferencesHomeFeed from './PreferencesHomeFeed'
 
@@ -74,10 +75,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ProfilePreviewModal.Component {...modal} />
   } else if (modal.name === 'server-input') {
     element = <ServerInputModal.Component {...modal} />
-  } else if (modal.name === 'report-post') {
-    element = <ReportPostModal.Component {...modal} />
-  } else if (modal.name === 'report-account') {
-    element = <ReportAccountModal.Component {...modal} />
+  } else if (modal.name === 'report') {
+    element = <ReportModal.Component {...modal} />
   } else if (modal.name === 'create-or-edit-mute-list') {
     element = <CreateOrEditMuteListModal.Component {...modal} />
   } else if (modal.name === 'list-add-remove-user') {
@@ -88,6 +87,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <DeleteAccountModal.Component />
   } else if (modal.name === 'repost') {
     element = <RepostModal.Component {...modal} />
+  } else if (modal.name === 'self-label') {
+    element = <SelfLabelModal.Component {...modal} />
   } else if (modal.name === 'change-handle') {
     element = <ChangeHandleModal.Component {...modal} />
   } else if (modal.name === 'waitlist') {
@@ -110,6 +111,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <PreferencesHomeFeed.Component />
   } else if (modal.name === 'onboarding') {
     element = <OnboardingModal.Component />
+  } else if (modal.name === 'moderation-details') {
+    element = <ModerationDetailsModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
new file mode 100644
index 000000000..b0e68e61b
--- /dev/null
+++ b/src/view/com/modals/ModerationDetails.tsx
@@ -0,0 +1,105 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {TextLink} from '../util/Link'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {listUriToHref} from 'lib/strings/url-helpers'
+import {Button} from '../util/forms/Button'
+
+export const snapPoints = [300]
+
+export function Component({
+  context,
+  moderation,
+}: {
+  context: 'account' | 'content'
+  moderation: ModerationUI
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  let name
+  let description
+  if (!moderation.cause) {
+    name = 'Content Warning'
+    description =
+      'Moderator has chosen to set a general warning on the content.'
+  } else if (moderation.cause.type === 'blocking') {
+    name = 'User Blocked'
+    description = 'You have blocked this user. You cannot view their content.'
+  } else if (moderation.cause.type === 'blocked-by') {
+    name = 'User Blocks You'
+    description = 'This user has blocked you. You cannot view their content.'
+  } else if (moderation.cause.type === 'block-other') {
+    name = 'Content Not Available'
+    description =
+      'This content is not available because one of the users involved has blocked the other.'
+  } else if (moderation.cause.type === 'muted') {
+    if (moderation.cause.source.type === 'list') {
+      const list = moderation.cause.source.list
+      name = <>Account Muted by List</>
+      description = (
+        <>
+          This user is included the{' '}
+          <TextLink
+            type="2xl"
+            href={listUriToHref(list.uri)}
+            text={list.name}
+            style={pal.link}
+          />{' '}
+          list which you have muted.
+        </>
+      )
+    } else {
+      name = 'Account Muted'
+      description = 'You have muted this user.'
+    }
+  } else {
+    name = moderation.cause.labelDef.strings[context].en.name
+    description = moderation.cause.labelDef.strings[context].en.description
+  }
+
+  return (
+    <View testID="moderationDetailsModal" style={[styles.container, pal.view]}>
+      <Text type="title-xl" style={[pal.text, styles.title]}>
+        {name}
+      </Text>
+      <Text type="2xl" style={[pal.text, styles.description]}>
+        {description}
+      </Text>
+      <View style={s.flex1} />
+      <Button
+        type="primary"
+        style={styles.btn}
+        onPress={() => store.shell.closeModal()}>
+        <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
+          Okay
+        </Text>
+      </Button>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingHorizontal: isDesktopWeb ? 0 : 14,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+  },
+  btn: {
+    paddingVertical: 14,
+    marginTop: isDesktopWeb ? 40 : 0,
+    marginBottom: isDesktopWeb ? 0 : 40,
+  },
+})
diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx
index d3267644b..4efe81225 100644
--- a/src/view/com/modals/ProfilePreview.tsx
+++ b/src/view/com/modals/ProfilePreview.tsx
@@ -1,63 +1,56 @@
-import React, {useState, useEffect, useCallback} from 'react'
-import {StyleSheet, View} from 'react-native'
+import React, {useState, useEffect} from 'react'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {useNavigation, StackActions} from '@react-navigation/native'
-import {Text} from '../util/text/Text'
+import {ThemedText} from '../util/text/ThemedText'
 import {useStores} from 'state/index'
 import {ProfileModel} from 'state/models/content/profile'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ProfileHeader} from '../profile/ProfileHeader'
-import {Button} from '../util/forms/Button'
-import {NavigationProp} from 'lib/routes/types'
+import {InfoCircleIcon} from 'lib/icons'
+import {useNavigationState} from '@react-navigation/native'
+import {isIOS} from 'platform/detection'
+import {s} from 'lib/styles'
 
-export const snapPoints = [560]
+export const snapPoints = [520, '100%']
 
 export const Component = observer(({did}: {did: string}) => {
   const store = useStores()
   const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
-  const navigation = useNavigation<NavigationProp>()
   const [model] = useState(new ProfileModel(store, {actor: did}))
   const {screen} = useAnalytics()
 
+  // track the navigator state to detect if a page-load occurred
+  const navState = useNavigationState(s => s)
+  const [initNavState] = useState(navState)
+  const isLoading = initNavState !== navState
+
   useEffect(() => {
     screen('Profile:Preview')
     model.setup()
   }, [model, screen])
 
-  const onPressViewProfile = useCallback(() => {
-    navigation.dispatch(StackActions.push('Profile', {name: model.handle}))
-    store.shell.closeModal()
-  }, [navigation, store, model])
-
   return (
-    <View style={pal.view}>
-      <View style={styles.headerWrapper}>
+    <View style={[pal.view, s.flex1]}>
+      <View
+        style={[
+          styles.headerWrapper,
+          isLoading && isIOS && styles.headerPositionAdjust,
+        ]}>
         <ProfileHeader view={model} hideBackButton onRefreshAll={() => {}} />
       </View>
-      <View style={[styles.buttonsContainer, pal.view]}>
-        <View style={styles.buttons}>
-          <Button
-            type="inverted"
-            style={[styles.button, styles.buttonWide]}
-            onPress={onPressViewProfile}
-            accessibilityLabel="View profile"
-            accessibilityHint="">
-            <Text type="button-lg" style={palInverted.text}>
-              View Profile
-            </Text>
-          </Button>
-          <Button
-            type="default"
-            style={styles.button}
-            onPress={() => store.shell.closeModal()}
-            accessibilityLabel="Close this preview"
-            accessibilityHint="">
-            <Text type="button-lg" style={pal.text}>
-              Close
-            </Text>
-          </Button>
+      <View style={[styles.hintWrapper, pal.view]}>
+        <View style={styles.hint}>
+          {isLoading ? (
+            <ActivityIndicator />
+          ) : (
+            <>
+              <InfoCircleIcon size={21} style={pal.textLight} />
+              <ThemedText type="xl" fg="light">
+                Swipe up to see more
+              </ThemedText>
+            </>
+          )}
         </View>
       </View>
     </View>
@@ -68,22 +61,18 @@ const styles = StyleSheet.create({
   headerWrapper: {
     height: 440,
   },
-  buttonsContainer: {
-    height: 120,
+  headerPositionAdjust: {
+    // HACK align the header for the profilescreen transition -prf
+    paddingTop: 23,
   },
-  buttons: {
-    flexDirection: 'row',
-    gap: 8,
-    paddingHorizontal: 14,
-    paddingTop: 16,
+  hintWrapper: {
+    height: 80,
   },
-  button: {
-    flex: 2,
+  hint: {
     flexDirection: 'row',
     justifyContent: 'center',
-    paddingVertical: 12,
-  },
-  buttonWide: {
-    flex: 3,
+    gap: 8,
+    paddingHorizontal: 14,
+    borderRadius: 6,
   },
 })
diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx
new file mode 100644
index 000000000..42863fd33
--- /dev/null
+++ b/src/view/com/modals/SelfLabel.tsx
@@ -0,0 +1,191 @@
+import React, {useState} from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../util/text/Text'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isDesktopWeb} from 'platform/detection'
+import {Button} from '../util/forms/Button'
+import {SelectableBtn} from '../util/forms/SelectableBtn'
+import {ScrollView} from 'view/com/modals/util'
+
+const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
+
+export const snapPoints = ['50%']
+
+export const Component = observer(function Component({
+  labels,
+  hasMedia,
+  onChange,
+}: {
+  labels: string[]
+  hasMedia: boolean
+  onChange: (labels: string[]) => void
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [selected, setSelected] = useState(labels)
+
+  const toggleAdultLabel = (label: string) => {
+    const hadLabel = selected.includes(label)
+    const stripped = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+    const final = !hadLabel ? stripped.concat([label]) : stripped
+    setSelected(final)
+    onChange(final)
+  }
+
+  const removeAdultLabel = () => {
+    const final = selected.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+    setSelected(final)
+    onChange(final)
+  }
+
+  const hasAdultSelection =
+    selected.includes('sexual') ||
+    selected.includes('nudity') ||
+    selected.includes('porn')
+  return (
+    <View testID="selfLabelModal" style={[pal.view, styles.container]}>
+      <View style={styles.titleSection}>
+        <Text type="title-lg" style={[pal.text, styles.title]}>
+          Add a content warning
+        </Text>
+      </View>
+
+      <ScrollView>
+        <View style={[styles.section, pal.border, {borderBottomWidth: 1}]}>
+          <View
+            style={{
+              flexDirection: 'row',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+              paddingBottom: 8,
+            }}>
+            <Text type="title" style={pal.text}>
+              Adult Content
+            </Text>
+            {hasAdultSelection ? (
+              <Button
+                type="default-light"
+                onPress={removeAdultLabel}
+                style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}>
+                <Text type="md" style={pal.link}>
+                  Remove
+                </Text>
+              </Button>
+            ) : null}
+          </View>
+          {hasMedia ? (
+            <>
+              <View style={s.flexRow}>
+                <SelectableBtn
+                  testID="sexualLabelBtn"
+                  selected={selected.includes('sexual')}
+                  left
+                  label="Suggestive"
+                  onSelect={() => toggleAdultLabel('sexual')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+                <SelectableBtn
+                  testID="nudityLabelBtn"
+                  selected={selected.includes('nudity')}
+                  label="Nudity"
+                  onSelect={() => toggleAdultLabel('nudity')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+                <SelectableBtn
+                  testID="pornLabelBtn"
+                  selected={selected.includes('porn')}
+                  label="Porn"
+                  right
+                  onSelect={() => toggleAdultLabel('porn')}
+                  accessibilityHint=""
+                  style={s.flex1}
+                />
+              </View>
+
+              <Text style={[pal.text, styles.adultExplainer]}>
+                {selected.includes('sexual') ? (
+                  <>Pictures meant for adults.</>
+                ) : selected.includes('nudity') ? (
+                  <>Artistic or non-erotic nudity.</>
+                ) : selected.includes('porn') ? (
+                  <>Sexual activity or erotic nudity.</>
+                ) : (
+                  <>If none are selected, suitable for all ages.</>
+                )}
+              </Text>
+            </>
+          ) : (
+            <View>
+              <Text style={[pal.textLight]}>
+                <Text type="md-bold" style={[pal.textLight]}>
+                  Not Applicable
+                </Text>
+                . This warning is only available for posts with media attached.
+              </Text>
+            </View>
+          )}
+        </View>
+      </ScrollView>
+
+      <View style={[styles.btnContainer, pal.borderDark]}>
+        <TouchableOpacity
+          testID="confirmBtn"
+          onPress={() => {
+            store.shell.closeModal()
+          }}
+          style={styles.btn}
+          accessibilityRole="button"
+          accessibilityLabel="Confirm"
+          accessibilityHint="">
+          <Text style={[s.white, s.bold, s.f18]}>Done</Text>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isDesktopWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isDesktopWeb ? 0 : 4,
+    paddingBottom: isDesktopWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  description: {
+    textAlign: 'center',
+    paddingHorizontal: 32,
+  },
+  section: {
+    borderTopWidth: 1,
+    paddingVertical: 20,
+    paddingHorizontal: isDesktopWeb ? 0 : 20,
+  },
+  adultExplainer: {
+    paddingLeft: 5,
+    paddingTop: 10,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+    paddingHorizontal: 20,
+  },
+})
diff --git a/src/view/com/modals/report/ReportPost.tsx b/src/view/com/modals/report/Modal.tsx
index 34ec8c2f2..f386b110d 100644
--- a/src/view/com/modals/report/ReportPost.tsx
+++ b/src/view/com/modals/report/Modal.tsx
@@ -1,10 +1,9 @@
 import React, {useState, useMemo} from 'react'
 import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
 import {ScrollView} from 'react-native-gesture-handler'
-import {ComAtprotoModerationDefs} from '@atproto/api'
+import {AtUri} from '@atproto/api'
 import {useStores} from 'state/index'
 import {s} from 'lib/styles'
-import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup'
 import {Text} from '../../util/text/Text'
 import * as Toast from '../../util/Toast'
 import {ErrorMessage} from '../../util/error/ErrorMessage'
@@ -12,25 +11,43 @@ import {cleanError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {SendReportButton} from './SendReportButton'
 import {InputIssueDetails} from './InputIssueDetails'
+import {ReportReasonOptions} from './ReasonOptions'
+import {CollectionId} from './types'
 
 const DMCA_LINK = 'https://bsky.app/support/copyright'
 
 export const snapPoints = [575]
 
-export function Component({
-  postUri,
-  postCid,
-}: {
-  postUri: string
-  postCid: string
-}) {
+const CollectionNames = {
+  [CollectionId.FeedGenerator]: 'Feed',
+  [CollectionId.Profile]: 'Profile',
+  [CollectionId.List]: 'List',
+  [CollectionId.Post]: 'Post',
+}
+
+type ReportComponentProps =
+  | {
+      uri: string
+      cid: string
+    }
+  | {
+      did: string
+    }
+
+export function Component(content: ReportComponentProps) {
   const store = useStores()
   const pal = usePalette('default')
   const [isProcessing, setIsProcessing] = useState(false)
-  const [showTextInput, setShowTextInput] = useState(false)
+  const [showDetailsInput, setShowDetailsInput] = useState(false)
   const [error, setError] = useState<string>()
   const [issue, setIssue] = useState<string>()
   const [details, setDetails] = useState<string>()
+  const isAccountReport = 'did' in content
+  const subjectKey = isAccountReport ? content.did : content.uri
+  const atUri = useMemo(
+    () => (!isAccountReport ? new AtUri(subjectKey) : null),
+    [isAccountReport, subjectKey],
+  )
 
   const submitReport = async () => {
     setError('')
@@ -43,12 +60,14 @@ export function Component({
         Linking.openURL(DMCA_LINK)
         return
       }
+      const $type = !isAccountReport
+        ? 'com.atproto.repo.strongRef'
+        : 'com.atproto.admin.defs#repoRef'
       await store.agent.createModerationReport({
         reasonType: issue,
         subject: {
-          $type: 'com.atproto.repo.strongRef',
-          uri: postUri,
-          cid: postCid,
+          $type,
+          ...content,
         },
         reason: details,
       })
@@ -63,13 +82,13 @@ export function Component({
   }
 
   const goBack = () => {
-    setShowTextInput(false)
+    setShowDetailsInput(false)
   }
 
   return (
-    <ScrollView testID="reportPostModal" style={[s.flex1, pal.view]}>
+    <ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
       <View style={styles.container}>
-        {showTextInput ? (
+        {showDetailsInput ? (
           <InputIssueDetails
             details={details}
             setDetails={setDetails}
@@ -79,12 +98,13 @@ export function Component({
           />
         ) : (
           <SelectIssue
-            setShowTextInput={setShowTextInput}
+            setShowDetailsInput={setShowDetailsInput}
             error={error}
             issue={issue}
             setIssue={setIssue}
             submitReport={submitReport}
             isProcessing={isProcessing}
+            atUri={atUri}
           />
         )}
       </View>
@@ -92,128 +112,59 @@ export function Component({
   )
 }
 
+// If no atUri is passed, that means the reporting collection is account
+const getCollectionNameForReport = (atUri: AtUri | null) => {
+  if (!atUri) return 'Account'
+  // Generic fallback for any collection being reported
+  return CollectionNames[atUri.collection as CollectionId] || 'Content'
+}
+
 const SelectIssue = ({
   error,
-  setShowTextInput,
+  setShowDetailsInput,
   issue,
   setIssue,
   submitReport,
   isProcessing,
+  atUri,
 }: {
   error: string | undefined
-  setShowTextInput: (v: boolean) => void
+  setShowDetailsInput: (v: boolean) => void
   issue: string | undefined
   setIssue: (v: string) => void
   submitReport: () => void
   isProcessing: boolean
+  atUri: AtUri | null
 }) => {
   const pal = usePalette('default')
-  const ITEMS: RadioGroupItem[] = useMemo(
-    () => [
-      {
-        key: ComAtprotoModerationDefs.REASONSPAM,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Spam
-            </Text>
-            <Text style={pal.textLight}>Excessive mentions or replies</Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONSEXUAL,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Unwanted Sexual Content
-            </Text>
-            <Text style={pal.textLight}>
-              Nudity or pornography not labeled as such
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: '__copyright__',
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Copyright Violation
-            </Text>
-            <Text style={pal.textLight}>Contains copyrighted material</Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONRUDE,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Anti-Social Behavior
-            </Text>
-            <Text style={pal.textLight}>
-              Harassment, trolling, or intolerance
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONVIOLATION,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Illegal and Urgent
-            </Text>
-            <Text style={pal.textLight}>
-              Glaring violations of law or terms of service
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONOTHER,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Other
-            </Text>
-            <Text style={pal.textLight}>
-              An issue not included in these options
-            </Text>
-          </View>
-        ),
-      },
-    ],
-    [pal],
-  )
-
+  const collectionName = getCollectionNameForReport(atUri)
   const onSelectIssue = (v: string) => setIssue(v)
   const goToDetails = () => {
     if (issue === '__copyright__') {
       Linking.openURL(DMCA_LINK)
       return
     }
-    setShowTextInput(true)
+    setShowDetailsInput(true)
   }
 
   return (
     <>
-      <Text style={[pal.text, styles.title]}>Report post</Text>
+      <Text style={[pal.text, styles.title]}>Report {collectionName}</Text>
       <Text style={[pal.textLight, styles.description]}>
-        What is the issue with this post?
+        What is the issue with this {collectionName}?
       </Text>
-      <RadioGroup
-        testID="reportPostRadios"
-        items={ITEMS}
-        onSelect={onSelectIssue}
+      <ReportReasonOptions
+        atUri={atUri}
+        selectedIssue={issue}
+        onSelectIssue={onSelectIssue}
       />
       {error ? (
         <View style={s.mt10}>
           <ErrorMessage message={error} />
         </View>
       ) : undefined}
-      {issue ? (
+      {/* If no atUri is present, the report would be for account in which case, we allow sending without specifying a reason */}
+      {issue || !atUri ? (
         <>
           <SendReportButton
             onPress={submitReport}
diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx
new file mode 100644
index 000000000..23b49b664
--- /dev/null
+++ b/src/view/com/modals/report/ReasonOptions.tsx
@@ -0,0 +1,123 @@
+import {View} from 'react-native'
+import React, {useMemo} from 'react'
+import {AtUri, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {Text} from '../../util/text/Text'
+import {UsePaletteValue, usePalette} from 'lib/hooks/usePalette'
+import {RadioGroup, RadioGroupItem} from 'view/com/util/forms/RadioGroup'
+import {CollectionId} from './types'
+
+type ReasonMap = Record<string, {title: string; description: string}>
+const CommonReasons = {
+  [ComAtprotoModerationDefs.REASONRUDE]: {
+    title: 'Anti-Social Behavior',
+    description: 'Harassment, trolling, or intolerance',
+  },
+  [ComAtprotoModerationDefs.REASONVIOLATION]: {
+    title: 'Illegal and Urgent',
+    description: 'Glaring violations of law or terms of service',
+  },
+  [ComAtprotoModerationDefs.REASONOTHER]: {
+    title: 'Other',
+    description: 'An issue not included in these options',
+  },
+}
+const CollectionToReasonsMap: Record<string, ReasonMap> = {
+  [CollectionId.Post]: {
+    [ComAtprotoModerationDefs.REASONSPAM]: {
+      title: 'Spam',
+      description: 'Excessive mentions or replies',
+    },
+    [ComAtprotoModerationDefs.REASONSEXUAL]: {
+      title: 'Unwanted Sexual Content',
+      description: 'Nudity or pornography not labeled as such',
+    },
+    __copyright__: {
+      title: 'Copyright Violation',
+      description: 'Contains copyrighted material',
+    },
+    ...CommonReasons,
+  },
+  [CollectionId.List]: {
+    ...CommonReasons,
+    [ComAtprotoModerationDefs.REASONVIOLATION]: {
+      title: 'Name or Description Violates Community Standards',
+      description: 'Terms used violate community standards',
+    },
+  },
+}
+const AccountReportReasons = {
+  [ComAtprotoModerationDefs.REASONMISLEADING]: {
+    title: 'Misleading Account',
+    description: 'Impersonation or false claims about identity or affiliation',
+  },
+  [ComAtprotoModerationDefs.REASONSPAM]: {
+    title: 'Frequently Posts Unwanted Content',
+    description: 'Spam; excessive mentions or replies',
+  },
+  [ComAtprotoModerationDefs.REASONVIOLATION]: {
+    title: 'Name or Description Violates Community Standards',
+    description: 'Terms used violate community standards',
+  },
+}
+
+const Option = ({
+  pal,
+  title,
+  description,
+}: {
+  pal: UsePaletteValue
+  description: string
+  title: string
+}) => {
+  return (
+    <View>
+      <Text style={pal.text} type="md-bold">
+        {title}
+      </Text>
+      <Text style={pal.textLight}>{description}</Text>
+    </View>
+  )
+}
+
+// This is mostly just content copy without almost any logic
+// so this may grow over time and it makes sense to split it up into its own file
+// to keep it separate from the actual reporting modal logic
+const useReportRadioOptions = (pal: UsePaletteValue, atUri: AtUri | null) =>
+  useMemo(() => {
+    let items: ReasonMap = {...CommonReasons}
+    // If no atUri is passed, that means the reporting collection is account
+    if (!atUri) {
+      items = {...AccountReportReasons}
+    }
+
+    if (atUri?.collection && CollectionToReasonsMap[atUri.collection]) {
+      items = {...CollectionToReasonsMap[atUri.collection]}
+    }
+
+    return Object.entries(items).map(([key, {title, description}]) => ({
+      key,
+      label: <Option pal={pal} title={title} description={description} />,
+    }))
+  }, [pal, atUri])
+
+export const ReportReasonOptions = ({
+  atUri,
+  selectedIssue,
+  onSelectIssue,
+}: {
+  atUri: AtUri | null
+  selectedIssue?: string
+  onSelectIssue: (key: string) => void
+}) => {
+  const pal = usePalette('default')
+  const ITEMS: RadioGroupItem[] = useReportRadioOptions(pal, atUri)
+  return (
+    <RadioGroup
+      items={ITEMS}
+      onSelect={onSelectIssue}
+      testID="reportReasonRadios"
+      initialSelection={selectedIssue}
+    />
+  )
+}
diff --git a/src/view/com/modals/report/ReportAccount.tsx b/src/view/com/modals/report/ReportAccount.tsx
deleted file mode 100644
index b53c54caa..000000000
--- a/src/view/com/modals/report/ReportAccount.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import React, {useState, useMemo} from 'react'
-import {TouchableOpacity, StyleSheet, View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
-import {ComAtprotoModerationDefs} from '@atproto/api'
-import {useStores} from 'state/index'
-import {s} from 'lib/styles'
-import {RadioGroup, RadioGroupItem} from '../../util/forms/RadioGroup'
-import {Text} from '../../util/text/Text'
-import * as Toast from '../../util/Toast'
-import {ErrorMessage} from '../../util/error/ErrorMessage'
-import {cleanError} from 'lib/strings/errors'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isDesktopWeb} from 'platform/detection'
-import {SendReportButton} from './SendReportButton'
-import {InputIssueDetails} from './InputIssueDetails'
-
-export const snapPoints = [500]
-
-export function Component({did}: {did: string}) {
-  const store = useStores()
-  const pal = usePalette('default')
-  const [isProcessing, setIsProcessing] = useState(false)
-  const [error, setError] = useState<string>()
-  const [issue, setIssue] = useState<string>()
-  const onSelectIssue = (v: string) => setIssue(v)
-  const [details, setDetails] = useState<string>()
-  const [showDetailsInput, setShowDetailsInput] = useState(false)
-
-  const onPress = async () => {
-    setError('')
-    if (!issue) {
-      return
-    }
-    setIsProcessing(true)
-    try {
-      await store.agent.com.atproto.moderation.createReport({
-        reasonType: issue,
-        subject: {
-          $type: 'com.atproto.admin.defs#repoRef',
-          did,
-        },
-        reason: details,
-      })
-      Toast.show("Thank you for your report! We'll look into it promptly.")
-      store.shell.closeModal()
-      return
-    } catch (e: any) {
-      setError(cleanError(e))
-      setIsProcessing(false)
-    }
-  }
-  const goBack = () => {
-    setShowDetailsInput(false)
-  }
-  const goToDetails = () => {
-    setShowDetailsInput(true)
-  }
-
-  return (
-    <ScrollView
-      testID="reportAccountModal"
-      style={[styles.container, pal.view]}>
-      {showDetailsInput ? (
-        <InputIssueDetails
-          submitReport={onPress}
-          setDetails={setDetails}
-          details={details}
-          isProcessing={isProcessing}
-          goBack={goBack}
-        />
-      ) : (
-        <SelectIssue
-          onPress={onPress}
-          onSelectIssue={onSelectIssue}
-          error={error}
-          isProcessing={isProcessing}
-          goToDetails={goToDetails}
-        />
-      )}
-    </ScrollView>
-  )
-}
-
-const SelectIssue = ({
-  onPress,
-  onSelectIssue,
-  error,
-  isProcessing,
-  goToDetails,
-}: {
-  onPress: () => void
-  onSelectIssue: (v: string) => void
-  error: string | undefined
-  isProcessing: boolean
-  goToDetails: () => void
-}) => {
-  const pal = usePalette('default')
-  const ITEMS: RadioGroupItem[] = useMemo(
-    () => [
-      {
-        key: ComAtprotoModerationDefs.REASONMISLEADING,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Misleading Account
-            </Text>
-            <Text style={pal.textLight}>
-              Impersonation or false claims about identity or affiliation
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONSPAM,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Frequently Posts Unwanted Content
-            </Text>
-            <Text style={pal.textLight}>
-              Spam; excessive mentions or replies
-            </Text>
-          </View>
-        ),
-      },
-      {
-        key: ComAtprotoModerationDefs.REASONVIOLATION,
-        label: (
-          <View>
-            <Text style={pal.text} type="md-bold">
-              Name or Description Violates Community Standards
-            </Text>
-            <Text style={pal.textLight}>
-              Terms used violate community standards
-            </Text>
-          </View>
-        ),
-      },
-    ],
-    [pal],
-  )
-  return (
-    <>
-      <Text type="title-xl" style={[pal.text, styles.title]}>
-        Report Account
-      </Text>
-      <Text type="xl" style={[pal.text, styles.description]}>
-        What is the issue with this account?
-      </Text>
-      <RadioGroup
-        testID="reportAccountRadios"
-        items={ITEMS}
-        onSelect={onSelectIssue}
-      />
-      <Text type="sm" style={[pal.text, styles.description, s.pt10]}>
-        For other issues, please report specific posts.
-      </Text>
-      {error ? (
-        <View style={s.mt10}>
-          <ErrorMessage message={error} />
-        </View>
-      ) : undefined}
-      <SendReportButton onPress={onPress} isProcessing={isProcessing} />
-      <TouchableOpacity
-        testID="addDetailsBtn"
-        style={styles.addDetailsBtn}
-        onPress={goToDetails}
-        accessibilityRole="button"
-        accessibilityLabel="Add details"
-        accessibilityHint="Add more details to your report">
-        <Text style={[s.f18, pal.link]}>Add details to report</Text>
-      </TouchableOpacity>
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingHorizontal: isDesktopWeb ? 0 : 10,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 22,
-    marginBottom: 10,
-  },
-  addDetailsBtn: {
-    padding: 14,
-    alignSelf: 'center',
-    marginBottom: 40,
-  },
-})
diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts
new file mode 100644
index 000000000..ca947ecbd
--- /dev/null
+++ b/src/view/com/modals/report/types.ts
@@ -0,0 +1,8 @@
+// TODO: ATM, @atproto/api does not export ids but it does have these listed at @atproto/api/client/lexicons
+// once we start exporting the ids from the @atproto/ap package, replace these hardcoded ones
+export enum CollectionId {
+  FeedGenerator = 'app.bsky.feed.generator',
+  Profile = 'app.bsky.actor.profile',
+  List = 'app.bsky.graph.list',
+  Post = 'app.bsky.feed.post',
+}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 7b9f0715b..7b07bb30f 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -7,7 +7,11 @@ import {
   StyleSheet,
   View,
 } from 'react-native'
-import {AppBskyEmbedImages} from '@atproto/api'
+import {
+  AppBskyEmbedImages,
+  ProfileModeration,
+  moderateProfile,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {
   FontAwesomeIcon,
@@ -31,11 +35,6 @@ import {Link, TextLink} from '../util/Link'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
-import {
-  getProfileViewBasicLabelInfo,
-  getProfileModeration,
-} from 'lib/labeling/helpers'
-import {ProfileModeration} from 'lib/labeling/types'
 import {formatCount} from '../util/numeric/format'
 import {makeProfileLink} from 'lib/routes/links'
 
@@ -99,9 +98,9 @@ export const FeedItem = observer(function ({
         handle: item.author.handle,
         displayName: item.author.displayName,
         avatar: item.author.avatar,
-        moderation: getProfileModeration(
-          store,
-          getProfileViewBasicLabelInfo(item.author),
+        moderation: moderateProfile(
+          item.author,
+          store.preferences.moderationOpts,
         ),
       },
       ...(item.additional?.map(({author}) => {
@@ -111,10 +110,7 @@ export const FeedItem = observer(function ({
           handle: author.handle,
           displayName: author.displayName,
           avatar: author.avatar,
-          moderation: getProfileModeration(
-            store,
-            getProfileViewBasicLabelInfo(author),
-          ),
+          moderation: moderateProfile(author, store.preferences.moderationOpts),
         }
       }) || []),
     ]
@@ -175,7 +171,7 @@ export const FeedItem = observer(function ({
     action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'`
     icon = 'HeartIconSolid'
     iconStyle = [
-      s.red3 as FontAwesomeIconStyle,
+      s.likeColor as FontAwesomeIconStyle,
       {position: 'relative', top: -4},
     ]
   } else {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 51f63dbb3..399e47006 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -20,25 +20,37 @@ import {ComposePrompt} from '../composer/Prompt'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {Text} from '../util/text/Text'
 import {s} from 'lib/styles'
-import {isDesktopWeb, isMobileWeb} from 'platform/detection'
+import {isIOS, isDesktopWeb, isMobileWeb} from 'platform/detection'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 
+const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 0}
+
+const PARENT_SPINNER = {
+  _reactKey: '__parent_spinner__',
+  _isHighlightedPost: false,
+}
 const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
 const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
 const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false}
+const CHILD_SPINNER = {
+  _reactKey: '__child_spinner__',
+  _isHighlightedPost: false,
+}
 const BOTTOM_COMPONENT = {
   _reactKey: '__bottom_component__',
   _isHighlightedPost: false,
 }
 type YieldedItem =
   | PostThreadItemModel
+  | typeof PARENT_SPINNER
   | typeof REPLY_PROMPT
   | typeof DELETED
   | typeof BLOCKED
+  | typeof PARENT_SPINNER
 
 export const PostThread = observer(function PostThread({
   uri,
@@ -51,14 +63,24 @@ export const PostThread = observer(function PostThread({
 }) {
   const pal = usePalette('default')
   const ref = useRef<FlatList>(null)
+  const hasScrolledIntoView = useRef<boolean>(false)
   const [isRefreshing, setIsRefreshing] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
   const posts = React.useMemo(() => {
     if (view.thread) {
-      return Array.from(flattenThread(view.thread)).concat([BOTTOM_COMPONENT])
+      const arr = Array.from(flattenThread(view.thread))
+      if (view.isLoadingFromCache) {
+        if (view.thread?.postRecord?.reply) {
+          arr.unshift(PARENT_SPINNER)
+        }
+        arr.push(CHILD_SPINNER)
+      } else {
+        arr.push(BOTTOM_COMPONENT)
+      }
+      return arr
     }
     return []
-  }, [view.thread])
+  }, [view.isLoadingFromCache, view.thread])
   useSetTitle(
     view.thread?.postRecord &&
       `${sanitizeDisplayName(
@@ -80,17 +102,37 @@ export const PostThread = observer(function PostThread({
     setIsRefreshing(false)
   }, [view, setIsRefreshing])
 
-  const onLayout = React.useCallback(() => {
+  const onContentSizeChange = React.useCallback(() => {
+    // only run once
+    if (hasScrolledIntoView.current) {
+      return
+    }
+
+    // wait for loading to finish
+    if (
+      !view.hasContent ||
+      (view.isFromCache && view.isLoadingFromCache) ||
+      view.isLoading
+    ) {
+      return
+    }
+
     const index = posts.findIndex(post => post._isHighlightedPost)
     if (index !== -1) {
       ref.current?.scrollToIndex({
         index,
         animated: false,
-        viewOffset: 40,
+        viewPosition: 0,
       })
+      hasScrolledIntoView.current = true
     }
-  }, [posts, ref])
-
+  }, [
+    posts,
+    view.hasContent,
+    view.isFromCache,
+    view.isLoadingFromCache,
+    view.isLoading,
+  ])
   const onScrollToIndexFailed = React.useCallback(
     (info: {
       index: number
@@ -115,7 +157,13 @@ export const PostThread = observer(function PostThread({
 
   const renderItem = React.useCallback(
     ({item}: {item: YieldedItem}) => {
-      if (item === REPLY_PROMPT) {
+      if (item === PARENT_SPINNER) {
+        return (
+          <View style={styles.parentSpinner}>
+            <ActivityIndicator />
+          </View>
+        )
+      } else if (item === REPLY_PROMPT) {
         return <ComposePrompt onPressCompose={onPressReply} />
       } else if (item === DELETED) {
         return (
@@ -150,6 +198,12 @@ export const PostThread = observer(function PostThread({
             ]}
           />
         )
+      } else if (item === CHILD_SPINNER) {
+        return (
+          <View style={styles.childSpinner}>
+            <ActivityIndicator />
+          </View>
+        )
       } else if (item instanceof PostThreadItemModel) {
         return <PostThreadItem item={item} onPostReply={onRefresh} />
       }
@@ -247,6 +301,11 @@ export const PostThread = observer(function PostThread({
       ref={ref}
       data={posts}
       initialNumToRender={posts.length}
+      maintainVisibleContentPosition={
+        isIOS && view.isFromCache
+          ? MAINTAIN_VISIBLE_CONTENT_POSITION
+          : undefined
+      }
       keyExtractor={item => item._reactKey}
       renderItem={renderItem}
       refreshControl={
@@ -257,10 +316,12 @@ export const PostThread = observer(function PostThread({
           titleColor={pal.colors.text}
         />
       }
-      onLayout={onLayout}
+      onContentSizeChange={
+        isIOS && view.isFromCache ? undefined : onContentSizeChange
+      }
       onScrollToIndexFailed={onScrollToIndexFailed}
       style={s.hContentRegion}
-      contentContainerStyle={s.contentContainerExtra}
+      contentContainerStyle={styles.contentContainerExtra}
     />
   )
 })
@@ -307,10 +368,17 @@ const styles = StyleSheet.create({
     paddingHorizontal: 18,
     paddingVertical: 18,
   },
+  parentSpinner: {
+    paddingVertical: 10,
+  },
+  childSpinner: {},
   bottomBorder: {
     borderBottomWidth: 1,
   },
   bottomSpacer: {
-    height: 200,
+    height: 400,
+  },
+  contentContainerExtra: {
+    paddingBottom: 500,
   },
 })
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index edf8d7749..8a56012f0 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -26,15 +26,14 @@ import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {formatCount} from '../util/numeric/format'
 import {TimeElapsed} from 'view/com/util/TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
-
-const PARENT_REPLY_LINE_LENGTH = 8
+import {isDesktopWeb} from 'platform/detection'
 
 export const PostThreadItem = observer(function PostThreadItem({
   item,
@@ -69,8 +68,7 @@ export const PostThreadItem = observer(function PostThreadItem({
   }, [item.post.uri, item.post.author])
   const repostsTitle = 'Reposts of this post'
 
-  const primaryLanguage = store.preferences.contentLanguages[0] || 'en'
-  const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '')
+  const translatorUrl = getTranslatorLink(record?.text || '')
   const needsTranslation = useMemo(
     () =>
       store.preferences.contentLanguages.length > 0 &&
@@ -159,159 +157,197 @@ export const PostThreadItem = observer(function PostThreadItem({
 
   if (item._isHighlightedPost) {
     return (
-      <PostHider
-        testID={`postThreadItem-by-${item.post.author.handle}`}
-        style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
-        moderation={item.moderation.thread}>
-        <PostSandboxWarning />
-        <View style={styles.layout}>
-          <View style={styles.layoutAvi}>
-            <PreviewableUserAvatar
-              size={52}
-              did={item.post.author.did}
-              handle={item.post.author.handle}
-              avatar={item.post.author.avatar}
-              moderation={item.moderation.avatar}
-            />
+      <>
+        {item.rootUri !== item.uri && (
+          <View style={{paddingLeft: 18, flexDirection: 'row', height: 16}}>
+            <View style={{width: 52}}>
+              <View
+                style={[
+                  styles.replyLine,
+                  {
+                    flexGrow: 1,
+                    backgroundColor: pal.colors.replyLine,
+                  },
+                ]}
+              />
+            </View>
           </View>
-          <View style={styles.layoutContent}>
-            <View style={[styles.meta, styles.metaExpandedLine1]}>
-              <View style={[s.flexRow]}>
+        )}
+
+        <Link
+          testID={`postThreadItem-by-${item.post.author.handle}`}
+          style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
+          noFeedback
+          accessible={false}>
+          <PostSandboxWarning />
+          <View style={styles.layout}>
+            <View style={[styles.layoutAvi, {paddingBottom: 8}]}>
+              <PreviewableUserAvatar
+                size={52}
+                did={item.post.author.did}
+                handle={item.post.author.handle}
+                avatar={item.post.author.avatar}
+                moderation={item.moderation.avatar}
+              />
+            </View>
+            <View style={styles.layoutContent}>
+              <View style={[styles.meta, styles.metaExpandedLine1]}>
+                <View style={[s.flexRow]}>
+                  <Link
+                    style={styles.metaItem}
+                    href={authorHref}
+                    title={authorTitle}>
+                    <Text
+                      type="xl-bold"
+                      style={[pal.text]}
+                      numberOfLines={1}
+                      lineHeight={1.2}>
+                      {sanitizeDisplayName(
+                        item.post.author.displayName ||
+                          sanitizeHandle(item.post.author.handle),
+                      )}
+                    </Text>
+                  </Link>
+                  <Text type="md" style={[styles.metaItem, pal.textLight]}>
+                    &middot;&nbsp;
+                    <TimeElapsed timestamp={item.post.indexedAt}>
+                      {({timeElapsed}) => <>{timeElapsed}</>}
+                    </TimeElapsed>
+                  </Text>
+                </View>
+              </View>
+              <View style={styles.meta}>
                 <Link
                   style={styles.metaItem}
                   href={authorHref}
                   title={authorTitle}>
-                  <Text
-                    type="xl-bold"
-                    style={[pal.text]}
-                    numberOfLines={1}
-                    lineHeight={1.2}>
-                    {sanitizeDisplayName(
-                      item.post.author.displayName ||
-                        sanitizeHandle(item.post.author.handle),
-                    )}
+                  <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                    {sanitizeHandle(item.post.author.handle, '@')}
                   </Text>
                 </Link>
-                <Text type="md" style={[styles.metaItem, pal.textLight]}>
-                  &middot;&nbsp;
-                  <TimeElapsed timestamp={item.post.indexedAt}>
-                    {({timeElapsed}) => <>{timeElapsed}</>}
-                  </TimeElapsed>
-                </Text>
-              </View>
-              <View style={s.flex1} />
-              <PostDropdownBtn
-                testID="postDropdownBtn"
-                itemUri={itemUri}
-                itemCid={itemCid}
-                itemHref={itemHref}
-                itemTitle={itemTitle}
-                isAuthor={item.post.author.did === store.me.did}
-                isThreadMuted={item.isThreadMuted}
-                onCopyPostText={onCopyPostText}
-                onOpenTranslate={onOpenTranslate}
-                onToggleThreadMute={onToggleThreadMute}
-                onDeletePost={onDeletePost}
-              />
-            </View>
-            <View style={styles.meta}>
-              <Link
-                style={styles.metaItem}
-                href={authorHref}
-                title={authorTitle}>
-                <Text type="md" style={[pal.textLight]} numberOfLines={1}>
-                  {sanitizeHandle(item.post.author.handle, '@')}
-                </Text>
-              </Link>
-            </View>
-          </View>
-        </View>
-        <View style={[s.pl10, s.pr10, s.pb10]}>
-          <ContentHider moderation={item.moderation.view}>
-            {item.richText?.text ? (
-              <View
-                style={[
-                  styles.postTextContainer,
-                  styles.postTextLargeContainer,
-                ]}>
-                <RichText
-                  type="post-text-lg"
-                  richText={item.richText}
-                  lineHeight={1.3}
-                  style={s.flex1}
-                />
               </View>
-            ) : undefined}
-            <ImageHider moderation={item.moderation.view} style={s.mb10}>
-              <PostEmbeds embed={item.post.embed} style={s.mb10} />
-            </ImageHider>
-          </ContentHider>
-          <ExpandedPostDetails
-            post={item.post}
-            translatorUrl={translatorUrl}
-            needsTranslation={needsTranslation}
-          />
-          {hasEngagement ? (
-            <View style={[styles.expandedInfo, pal.border]}>
-              {item.post.repostCount ? (
-                <Link
-                  style={styles.expandedInfoItem}
-                  href={repostsHref}
-                  title={repostsTitle}>
-                  <Text testID="repostCount" type="lg" style={pal.textLight}>
-                    <Text type="xl-bold" style={pal.text}>
-                      {formatCount(item.post.repostCount)}
-                    </Text>{' '}
-                    {pluralize(item.post.repostCount, 'repost')}
-                  </Text>
-                </Link>
-              ) : (
-                <></>
-              )}
-              {item.post.likeCount ? (
-                <Link
-                  style={styles.expandedInfoItem}
-                  href={likesHref}
-                  title={likesTitle}>
-                  <Text testID="likeCount" type="lg" style={pal.textLight}>
-                    <Text type="xl-bold" style={pal.text}>
-                      {formatCount(item.post.likeCount)}
-                    </Text>{' '}
-                    {pluralize(item.post.likeCount, 'like')}
-                  </Text>
-                </Link>
-              ) : (
-                <></>
-              )}
             </View>
-          ) : (
-            <></>
-          )}
-          <View style={[s.pl10, s.pb5]}>
-            <PostCtrls
-              big
+            <PostDropdownBtn
+              testID="postDropdownBtn"
               itemUri={itemUri}
               itemCid={itemCid}
               itemHref={itemHref}
               itemTitle={itemTitle}
-              author={item.post.author}
-              text={item.richText?.text || record.text}
-              indexedAt={item.post.indexedAt}
               isAuthor={item.post.author.did === store.me.did}
-              isReposted={!!item.post.viewer?.repost}
-              isLiked={!!item.post.viewer?.like}
               isThreadMuted={item.isThreadMuted}
-              onPressReply={onPressReply}
-              onPressToggleRepost={onPressToggleRepost}
-              onPressToggleLike={onPressToggleLike}
               onCopyPostText={onCopyPostText}
               onOpenTranslate={onOpenTranslate}
               onToggleThreadMute={onToggleThreadMute}
               onDeletePost={onDeletePost}
+              style={{
+                paddingVertical: 6,
+                paddingHorizontal: 10,
+                marginLeft: 'auto',
+                width: 40,
+              }}
+            />
+          </View>
+          <View style={[s.pl10, s.pr10, s.pb10]}>
+            <ContentHider
+              moderation={item.moderation.content}
+              ignoreMute
+              style={styles.contentHider}
+              childContainerStyle={styles.contentHiderChild}>
+              <PostAlerts
+                moderation={item.moderation.content}
+                includeMute
+                style={styles.alert}
+              />
+              {item.richText?.text ? (
+                <View
+                  style={[
+                    styles.postTextContainer,
+                    styles.postTextLargeContainer,
+                  ]}>
+                  <RichText
+                    type="post-text-lg"
+                    richText={item.richText}
+                    lineHeight={1.3}
+                    style={s.flex1}
+                  />
+                </View>
+              ) : undefined}
+              {item.post.embed && (
+                <ContentHider moderation={item.moderation.embed} style={s.mb10}>
+                  <PostEmbeds
+                    embed={item.post.embed}
+                    moderation={item.moderation.embed}
+                  />
+                </ContentHider>
+              )}
+            </ContentHider>
+            <ExpandedPostDetails
+              post={item.post}
+              translatorUrl={translatorUrl}
+              needsTranslation={needsTranslation}
             />
+            {hasEngagement ? (
+              <View style={[styles.expandedInfo, pal.border]}>
+                {item.post.repostCount ? (
+                  <Link
+                    style={styles.expandedInfoItem}
+                    href={repostsHref}
+                    title={repostsTitle}>
+                    <Text testID="repostCount" type="lg" style={pal.textLight}>
+                      <Text type="xl-bold" style={pal.text}>
+                        {formatCount(item.post.repostCount)}
+                      </Text>{' '}
+                      {pluralize(item.post.repostCount, 'repost')}
+                    </Text>
+                  </Link>
+                ) : (
+                  <></>
+                )}
+                {item.post.likeCount ? (
+                  <Link
+                    style={styles.expandedInfoItem}
+                    href={likesHref}
+                    title={likesTitle}>
+                    <Text testID="likeCount" type="lg" style={pal.textLight}>
+                      <Text type="xl-bold" style={pal.text}>
+                        {formatCount(item.post.likeCount)}
+                      </Text>{' '}
+                      {pluralize(item.post.likeCount, 'like')}
+                    </Text>
+                  </Link>
+                ) : (
+                  <></>
+                )}
+              </View>
+            ) : (
+              <></>
+            )}
+            <View style={[s.pl10, s.pb5]}>
+              <PostCtrls
+                big
+                itemUri={itemUri}
+                itemCid={itemCid}
+                itemHref={itemHref}
+                itemTitle={itemTitle}
+                author={item.post.author}
+                text={item.richText?.text || record.text}
+                indexedAt={item.post.indexedAt}
+                isAuthor={item.post.author.did === store.me.did}
+                isReposted={!!item.post.viewer?.repost}
+                isLiked={!!item.post.viewer?.like}
+                isThreadMuted={item.isThreadMuted}
+                onPressReply={onPressReply}
+                onPressToggleRepost={onPressToggleRepost}
+                onPressToggleLike={onPressToggleLike}
+                onCopyPostText={onCopyPostText}
+                onOpenTranslate={onOpenTranslate}
+                onToggleThreadMute={onToggleThreadMute}
+                onDeletePost={onDeletePost}
+              />
+            </View>
           </View>
-        </View>
-      </PostHider>
+        </Link>
+      </>
     )
   } else {
     return (
@@ -324,26 +360,36 @@ export const PostThreadItem = observer(function PostThreadItem({
             pal.border,
             pal.view,
             item._showParentReplyLine && styles.noTopBorder,
+            !item._showChildReplyLine && {borderBottomWidth: 1},
           ]}
-          moderation={item.moderation.thread}>
-          {item._showParentReplyLine && (
-            <View
-              style={[
-                styles.parentReplyLine,
-                {borderColor: pal.colors.replyLine},
-              ]}
-            />
-          )}
-          {item._showChildReplyLine && (
-            <View
-              style={[
-                styles.childReplyLine,
-                {borderColor: pal.colors.replyLine},
-              ]}
-            />
-          )}
+          moderation={item.moderation.content}>
           <PostSandboxWarning />
-          <View style={styles.layout}>
+
+          <View
+            style={{flexDirection: 'row', gap: 10, paddingLeft: 8, height: 16}}>
+            <View style={{width: 52}}>
+              {item._showParentReplyLine && (
+                <View
+                  style={[
+                    styles.replyLine,
+                    {
+                      flexGrow: 1,
+                      backgroundColor: pal.colors.replyLine,
+                      marginBottom: 4,
+                    },
+                  ]}
+                />
+              )}
+            </View>
+          </View>
+
+          <View
+            style={[
+              styles.layout,
+              {
+                paddingBottom: item._showChildReplyLine ? 0 : 8,
+              },
+            ]}>
             <View style={styles.layoutAvi}>
               <PreviewableUserAvatar
                 size={52}
@@ -352,7 +398,21 @@ export const PostThreadItem = observer(function PostThreadItem({
                 avatar={item.post.author.avatar}
                 moderation={item.moderation.avatar}
               />
+
+              {item._showChildReplyLine && (
+                <View
+                  style={[
+                    styles.replyLine,
+                    {
+                      flexGrow: 1,
+                      backgroundColor: pal.colors.replyLine,
+                      marginTop: 4,
+                    },
+                  ]}
+                />
+              )}
             </View>
+
             <View style={styles.layoutContent}>
               <PostMeta
                 author={item.post.author}
@@ -360,32 +420,39 @@ export const PostThreadItem = observer(function PostThreadItem({
                 timestamp={item.post.indexedAt}
                 postHref={itemHref}
               />
-              <ContentHider
-                moderation={item.moderation.thread}
-                containerStyle={styles.contentHider}>
-                {item.richText?.text ? (
-                  <View style={styles.postTextContainer}>
-                    <RichText
-                      type="post-text"
-                      richText={item.richText}
-                      style={[pal.text, s.flex1]}
-                      lineHeight={1.3}
-                    />
-                  </View>
-                ) : undefined}
-                <ImageHider style={s.mb10} moderation={item.moderation.thread}>
-                  <PostEmbeds embed={item.post.embed} style={s.mb10} />
-                </ImageHider>
-                {needsTranslation && (
-                  <View style={[pal.borderDark, styles.translateLink]}>
-                    <Link href={translatorUrl} title="Translate">
-                      <Text type="sm" style={pal.link}>
-                        Translate this post
-                      </Text>
-                    </Link>
-                  </View>
-                )}
-              </ContentHider>
+              <PostAlerts
+                moderation={item.moderation.content}
+                style={styles.alert}
+              />
+              {item.richText?.text ? (
+                <View style={styles.postTextContainer}>
+                  <RichText
+                    type="post-text"
+                    richText={item.richText}
+                    style={[pal.text, s.flex1]}
+                    lineHeight={1.3}
+                  />
+                </View>
+              ) : undefined}
+              {item.post.embed && (
+                <ContentHider
+                  style={styles.contentHider}
+                  moderation={item.moderation.embed}>
+                  <PostEmbeds
+                    embed={item.post.embed}
+                    moderation={item.moderation.embed}
+                  />
+                </ContentHider>
+              )}
+              {needsTranslation && (
+                <View style={[pal.borderDark, styles.translateLink]}>
+                  <Link href={translatorUrl} title="Translate">
+                    <Text type="sm" style={pal.link}>
+                      Translate this post
+                    </Text>
+                  </Link>
+                </View>
+              )}
               <PostCtrls
                 itemUri={itemUri}
                 itemCid={itemCid}
@@ -416,7 +483,7 @@ export const PostThreadItem = observer(function PostThreadItem({
           <Link
             style={[
               styles.loadMore,
-              {borderTopColor: pal.colors.border},
+              {borderBottomColor: pal.colors.border},
               pal.view,
             ]}
             href={itemHref}
@@ -466,41 +533,22 @@ const styles = StyleSheet.create({
     paddingLeft: 10,
   },
   outerHighlighted: {
-    paddingTop: 2,
-    paddingLeft: 6,
-    paddingRight: 6,
+    paddingTop: 16,
+    paddingLeft: 10,
+    paddingRight: 10,
   },
   noTopBorder: {
     borderTopWidth: 0,
   },
-  parentReplyLine: {
-    position: 'absolute',
-    left: 44,
-    top: -1 * PARENT_REPLY_LINE_LENGTH + 6,
-    height: PARENT_REPLY_LINE_LENGTH,
-    borderLeftWidth: 2,
-  },
-  childReplyLine: {
-    position: 'absolute',
-    left: 44,
-    top: 65,
-    bottom: 0,
-    borderLeftWidth: 2,
-  },
   layout: {
     flexDirection: 'row',
+    gap: 10,
+    paddingLeft: 8,
   },
-  layoutAvi: {
-    paddingLeft: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
-    marginRight: 10,
-  },
+  layoutAvi: {},
   layoutContent: {
     flex: 1,
     paddingRight: 10,
-    paddingTop: 10,
-    paddingBottom: 10,
   },
   meta: {
     flexDirection: 'row',
@@ -513,7 +561,10 @@ const styles = StyleSheet.create({
   },
   metaItem: {
     paddingRight: 5,
-    maxWidth: 240,
+    maxWidth: isDesktopWeb ? 380 : 220,
+  },
+  alert: {
+    marginBottom: 6,
   },
   postTextContainer: {
     flexDirection: 'row',
@@ -521,7 +572,6 @@ const styles = StyleSheet.create({
     flexWrap: 'wrap',
     paddingBottom: 8,
     paddingRight: 10,
-    minHeight: 36,
   },
   postTextLargeContainer: {
     paddingHorizontal: 0,
@@ -531,7 +581,10 @@ const styles = StyleSheet.create({
     marginBottom: 6,
   },
   contentHider: {
-    marginTop: 4,
+    marginBottom: 6,
+  },
+  contentHiderChild: {
+    marginTop: 6,
   },
   expandedInfo: {
     flexDirection: 'row',
@@ -547,10 +600,14 @@ const styles = StyleSheet.create({
   loadMore: {
     flexDirection: 'row',
     justifyContent: 'space-between',
-    borderTopWidth: 1,
+    borderBottomWidth: 1,
     paddingLeft: 80,
     paddingRight: 20,
-    paddingVertical: 10,
-    marginBottom: 8,
+    paddingVertical: 12,
+  },
+  replyLine: {
+    width: 2,
+    marginLeft: 'auto',
+    marginRight: 'auto',
   },
 })
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index ac5e7d20b..673ddefcf 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -19,9 +19,8 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {Text} from '../util/text/Text'
 import {RichText} from '../util/text/RichText'
 import * as Toast from '../util/Toast'
@@ -134,8 +133,7 @@ const PostLoaded = observer(
       replyAuthorDid = urip.hostname
     }
 
-    const primaryLanguage = store.preferences.contentLanguages[0] || 'en'
-    const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '')
+    const translatorUrl = getTranslatorLink(record?.text || '')
     const needsTranslation = useMemo(
       () =>
         store.preferences.contentLanguages.length > 0 &&
@@ -206,10 +204,7 @@ const PostLoaded = observer(
     }, [item, setDeleted, store])
 
     return (
-      <PostHider
-        href={itemHref}
-        style={[styles.outer, pal.view, pal.border, style]}
-        moderation={item.moderation.list}>
+      <Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}>
         {showReplyLine && <View style={styles.replyLine} />}
         <View style={styles.layout}>
           <View style={styles.layoutAvi}>
@@ -251,8 +246,13 @@ const PostLoaded = observer(
               </View>
             )}
             <ContentHider
-              moderation={item.moderation.list}
-              containerStyle={styles.contentHider}>
+              moderation={item.moderation.content}
+              style={styles.contentHider}
+              childContainerStyle={styles.contentHiderChild}>
+              <PostAlerts
+                moderation={item.moderation.content}
+                style={styles.alert}
+              />
               {item.richText?.text ? (
                 <View style={styles.postTextContainer}>
                   <RichText
@@ -264,9 +264,16 @@ const PostLoaded = observer(
                   />
                 </View>
               ) : undefined}
-              <ImageHider moderation={item.moderation.list} style={s.mb10}>
-                <PostEmbeds embed={item.post.embed} style={s.mb10} />
-              </ImageHider>
+              {item.post.embed ? (
+                <ContentHider
+                  moderation={item.moderation.embed}
+                  style={styles.contentHider}>
+                  <PostEmbeds
+                    embed={item.post.embed}
+                    moderation={item.moderation.embed}
+                  />
+                </ContentHider>
+              ) : null}
               {needsTranslation && (
                 <View style={[pal.borderDark, styles.translateLink]}>
                   <Link href={translatorUrl} title="Translate">
@@ -302,15 +309,17 @@ const PostLoaded = observer(
             />
           </View>
         </View>
-      </PostHider>
+      </Link>
     )
   },
 )
 
 const styles = StyleSheet.create({
   outer: {
-    padding: 10,
+    paddingTop: 10,
     paddingRight: 15,
+    paddingBottom: 5,
+    paddingLeft: 10,
     borderTopWidth: 1,
   },
   layout: {
@@ -323,11 +332,13 @@ const styles = StyleSheet.create({
   layoutContent: {
     flex: 1,
   },
+  alert: {
+    marginBottom: 6,
+  },
   postTextContainer: {
     flexDirection: 'row',
     alignItems: 'center',
     flexWrap: 'wrap',
-    paddingBottom: 8,
   },
   translateLink: {
     marginBottom: 12,
@@ -341,6 +352,9 @@ const styles = StyleSheet.create({
     borderLeftColor: colors.gray2,
   },
   contentHider: {
-    marginTop: 4,
+    marginBottom: 2,
+  },
+  contentHiderChild: {
+    marginTop: 6,
   },
 })
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 75c321145..e1212f32c 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -8,16 +8,14 @@ import {
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {PostsFeedItemModel} from 'state/models/feeds/post'
-import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {Link, DesktopWebTextLink} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
-import {PostHider} from '../util/moderation/PostHider'
 import {ContentHider} from '../util/moderation/ContentHider'
-import {ImageHider} from '../util/moderation/ImageHider'
+import {PostAlerts} from '../util/moderation/PostAlerts'
 import {RichText} from '../util/text/RichText'
 import {PostSandboxWarning} from '../util/PostSandboxWarning'
 import * as Toast from '../util/Toast'
@@ -34,14 +32,14 @@ import {makeProfileLink} from 'lib/routes/links'
 export const FeedItem = observer(function ({
   item,
   isThreadChild,
+  isThreadLastChild,
   isThreadParent,
-  ignoreMuteFor,
 }: {
   item: PostsFeedItemModel
   isThreadChild?: boolean
+  isThreadLastChild?: boolean
   isThreadParent?: boolean
   showReplyLine?: boolean
-  ignoreMuteFor?: string
 }) {
   const store = useStores()
   const pal = usePalette('default')
@@ -62,8 +60,7 @@ export const FeedItem = observer(function ({
     const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri)
     return urip.hostname
   }, [record?.reply])
-  const primaryLanguage = store.preferences.contentLanguages[0] || 'en'
-  const translatorUrl = getTranslatorLink(primaryLanguage, record?.text || '')
+  const translatorUrl = getTranslatorLink(record?.text || '')
   const needsTranslation = useMemo(
     () =>
       store.preferences.contentLanguages.length > 0 &&
@@ -138,80 +135,86 @@ export const FeedItem = observer(function ({
     )
   }, [track, item, setDeleted, store])
 
-  const isSmallTop = isThreadChild
   const outerStyles = [
     styles.outer,
     pal.view,
-    {borderColor: pal.colors.border},
-    isSmallTop ? styles.outerSmallTop : undefined,
-    isThreadParent ? styles.outerNoBottom : undefined,
+    {
+      borderColor: pal.colors.border,
+      paddingBottom:
+        isThreadLastChild || (!isThreadChild && !isThreadParent)
+          ? 6
+          : undefined,
+    },
+    isThreadChild ? styles.outerSmallTop : undefined,
   ]
 
-  // moderation override
-  let moderation = item.moderation.list
-  if (
-    ignoreMuteFor === item.post.author.did &&
-    moderation.isMute &&
-    !moderation.noOverride
-  ) {
-    moderation = {behavior: ModerationBehaviorCode.Show}
-  }
-
   if (!record || deleted) {
     return <View />
   }
 
   return (
-    <PostHider
+    <Link
       testID={`feedItem-by-${item.post.author.handle}`}
       style={outerStyles}
       href={itemHref}
-      moderation={moderation}>
-      {isThreadChild && (
-        <View
-          style={[styles.topReplyLine, {borderColor: pal.colors.replyLine}]}
-        />
-      )}
-      {isThreadParent && (
-        <View
-          style={[styles.bottomReplyLine, {borderColor: pal.colors.replyLine}]}
-        />
-      )}
-      {item.reasonRepost && (
-        <Link
-          style={styles.includeReason}
-          href={makeProfileLink(item.reasonRepost.by)}
-          title={sanitizeDisplayName(
-            item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
-          )}>
-          <FontAwesomeIcon
-            icon="retweet"
-            style={[
-              styles.includeReasonIcon,
-              {color: pal.colors.textLight} as FontAwesomeIconStyle,
-            ]}
-          />
-          <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}
-              text={sanitizeDisplayName(
-                item.reasonRepost.by.displayName ||
-                  sanitizeHandle(item.reasonRepost.by.handle),
-              )}
-              href={makeProfileLink(item.reasonRepost.by)}
-            />
-          </Text>
-        </Link>
-      )}
+      noFeedback
+      accessible={false}>
       <PostSandboxWarning />
+
+      <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
+        <View style={{width: 52}}>
+          {isThreadChild && (
+            <View
+              style={[
+                styles.replyLine,
+                {
+                  flexGrow: 1,
+                  backgroundColor: pal.colors.replyLine,
+                  marginBottom: 4,
+                },
+              ]}
+            />
+          )}
+        </View>
+
+        <View style={{paddingTop: 12}}>
+          {item.reasonRepost && (
+            <Link
+              style={styles.includeReason}
+              href={makeProfileLink(item.reasonRepost.by)}
+              title={sanitizeDisplayName(
+                item.reasonRepost.by.displayName || item.reasonRepost.by.handle,
+              )}>
+              <FontAwesomeIcon
+                icon="retweet"
+                style={[
+                  styles.includeReasonIcon,
+                  {color: pal.colors.textLight} as FontAwesomeIconStyle,
+                ]}
+              />
+              <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}
+                  text={sanitizeDisplayName(
+                    item.reasonRepost.by.displayName ||
+                      sanitizeHandle(item.reasonRepost.by.handle),
+                  )}
+                  href={makeProfileLink(item.reasonRepost.by)}
+                />
+              </Text>
+            </Link>
+          )}
+        </View>
+      </View>
+
       <View style={styles.layout}>
         <View style={styles.layoutAvi}>
           <PreviewableUserAvatar
@@ -221,6 +224,18 @@ export const FeedItem = observer(function ({
             avatar={item.post.author.avatar}
             moderation={item.moderation.avatar}
           />
+          {isThreadParent && (
+            <View
+              style={[
+                styles.replyLine,
+                {
+                  flexGrow: 1,
+                  backgroundColor: pal.colors.replyLine,
+                  marginTop: 4,
+                },
+              ]}
+            />
+          )}
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
@@ -255,8 +270,14 @@ export const FeedItem = observer(function ({
             </View>
           )}
           <ContentHider
-            moderation={moderation}
-            containerStyle={styles.contentHider}>
+            testID="contentHider-post"
+            moderation={item.moderation.content}
+            ignoreMute
+            childContainerStyle={styles.contentHiderChild}>
+            <PostAlerts
+              moderation={item.moderation.content}
+              style={styles.alert}
+            />
             {item.richText?.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
@@ -267,9 +288,17 @@ export const FeedItem = observer(function ({
                 />
               </View>
             ) : undefined}
-            <ImageHider moderation={item.moderation.list} style={styles.embed}>
-              <PostEmbeds embed={item.post.embed} style={styles.embed} />
-            </ImageHider>
+            {item.post.embed ? (
+              <ContentHider
+                testID="contentHider-embed"
+                moderation={item.moderation.embed}
+                style={styles.embed}>
+                <PostEmbeds
+                  embed={item.post.embed}
+                  moderation={item.moderation.embed}
+                />
+              </ContentHider>
+            ) : null}
             {needsTranslation && (
               <View style={[pal.borderDark, styles.translateLink]}>
                 <Link href={translatorUrl} title="Translate">
@@ -281,7 +310,6 @@ export const FeedItem = observer(function ({
             )}
           </ContentHider>
           <PostCtrls
-            style={styles.ctrls}
             itemUri={itemUri}
             itemCid={itemCid}
             itemHref={itemHref}
@@ -306,43 +334,29 @@ export const FeedItem = observer(function ({
           />
         </View>
       </View>
-    </PostHider>
+    </Link>
   )
 })
 
 const styles = StyleSheet.create({
   outer: {
     borderTopWidth: 1,
-    padding: 10,
+    paddingLeft: 10,
     paddingRight: 15,
-    paddingBottom: 8,
   },
   outerSmallTop: {
     borderTopWidth: 0,
   },
-  outerNoBottom: {
-    paddingBottom: 2,
-  },
-  topReplyLine: {
-    position: 'absolute',
-    left: 42,
-    top: 0,
-    height: 6,
-    borderLeftWidth: 2,
-  },
-  bottomReplyLine: {
-    position: 'absolute',
-    left: 42,
-    top: 72,
-    bottom: 0,
-    borderLeftWidth: 2,
+  replyLine: {
+    width: 2,
+    marginLeft: 'auto',
+    marginRight: 'auto',
   },
   includeReason: {
     flexDirection: 'row',
-    paddingLeft: 50,
-    paddingRight: 20,
     marginTop: 2,
-    marginBottom: 2,
+    marginBottom: 4,
+    marginLeft: -20,
   },
   includeReasonIcon: {
     marginRight: 4,
@@ -358,14 +372,18 @@ const styles = StyleSheet.create({
   layoutContent: {
     flex: 1,
   },
+  alert: {
+    marginTop: 6,
+    marginBottom: 6,
+  },
   postTextContainer: {
     flexDirection: 'row',
     alignItems: 'center',
     flexWrap: 'wrap',
     paddingBottom: 4,
   },
-  contentHider: {
-    marginTop: 4,
+  contentHiderChild: {
+    marginTop: 6,
   },
   embed: {
     marginBottom: 6,
@@ -373,7 +391,4 @@ const styles = StyleSheet.create({
   translateLink: {
     marginBottom: 6,
   },
-  ctrls: {
-    marginTop: 4,
-  },
 })
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index b73d4a99d..6fc169db9 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import {observer} from 'mobx-react-lite'
 import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice'
 import {AtUri} from '@atproto/api'
 import {Link} from '../util/Link'
@@ -7,65 +8,65 @@ import {Text} from '../util/text/Text'
 import Svg, {Circle, Line} from 'react-native-svg'
 import {FeedItem} from './FeedItem'
 import {usePalette} from 'lib/hooks/usePalette'
-import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {makeProfileLink} from 'lib/routes/links'
 
-export function FeedSlice({
-  slice,
-  ignoreMuteFor,
-}: {
-  slice: PostsFeedSliceModel
-  ignoreMuteFor?: string
-}) {
-  if (slice.moderation.list.behavior === ModerationBehaviorCode.Hide) {
-    if (!ignoreMuteFor && !slice.moderation.list.noOverride) {
+export const FeedSlice = observer(
+  ({
+    slice,
+    ignoreFilterFor,
+  }: {
+    slice: PostsFeedSliceModel
+    ignoreFilterFor?: string
+  }) => {
+    if (slice.shouldFilter(ignoreFilterFor)) {
       return null
     }
-  }
-  if (slice.isThread && slice.items.length > 3) {
-    const last = slice.items.length - 1
+
+    if (slice.isThread && slice.items.length > 3) {
+      const last = slice.items.length - 1
+      return (
+        <>
+          <FeedItem
+            key={slice.items[0]._reactKey}
+            item={slice.items[0]}
+            isThreadParent={slice.isThreadParentAt(0)}
+            isThreadChild={slice.isThreadChildAt(0)}
+          />
+          <FeedItem
+            key={slice.items[1]._reactKey}
+            item={slice.items[1]}
+            isThreadParent={slice.isThreadParentAt(1)}
+            isThreadChild={slice.isThreadChildAt(1)}
+          />
+          <ViewFullThread slice={slice} />
+          <FeedItem
+            key={slice.items[last]._reactKey}
+            item={slice.items[last]}
+            isThreadParent={slice.isThreadParentAt(last)}
+            isThreadChild={slice.isThreadChildAt(last)}
+            isThreadLastChild
+          />
+        </>
+      )
+    }
+
     return (
       <>
-        <FeedItem
-          key={slice.items[0]._reactKey}
-          item={slice.items[0]}
-          isThreadParent={slice.isThreadParentAt(0)}
-          isThreadChild={slice.isThreadChildAt(0)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-        <FeedItem
-          key={slice.items[1]._reactKey}
-          item={slice.items[1]}
-          isThreadParent={slice.isThreadParentAt(1)}
-          isThreadChild={slice.isThreadChildAt(1)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-        <ViewFullThread slice={slice} />
-        <FeedItem
-          key={slice.items[last]._reactKey}
-          item={slice.items[last]}
-          isThreadParent={slice.isThreadParentAt(last)}
-          isThreadChild={slice.isThreadChildAt(last)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
+        {slice.items.map((item, i) => (
+          <FeedItem
+            key={item._reactKey}
+            item={item}
+            isThreadParent={slice.isThreadParentAt(i)}
+            isThreadChild={slice.isThreadChildAt(i)}
+            isThreadLastChild={
+              slice.isThreadChildAt(i) && slice.items.length === i + 1
+            }
+          />
+        ))}
       </>
     )
-  }
-
-  return (
-    <>
-      {slice.items.map((item, i) => (
-        <FeedItem
-          key={item._reactKey}
-          item={item}
-          isThreadParent={slice.isThreadParentAt(i)}
-          isThreadChild={slice.isThreadChildAt(i)}
-          ignoreMuteFor={ignoreMuteFor}
-        />
-      ))}
-    </>
-  )
-}
+  },
+)
 
 function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
   const pal = usePalette('default')
@@ -75,23 +76,28 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
   }, [slice.rootItem.post.uri, slice.rootItem.post.author])
 
   return (
-    <Link style={[pal.view, styles.viewFullThread]} href={itemHref} noFeedback>
+    <Link
+      style={[pal.view, styles.viewFullThread]}
+      href={itemHref}
+      asAnchor
+      noFeedback>
       <View style={styles.viewFullThreadDots}>
-        <Svg width="4" height="30">
+        <Svg width="4" height="40">
           <Line
             x1="2"
             y1="0"
             x2="2"
-            y2="8"
+            y2="15"
             stroke={pal.colors.replyLine}
             strokeWidth="2"
           />
-          <Circle cx="2" cy="16" r="1.5" fill={pal.colors.replyLineDot} />
           <Circle cx="2" cy="22" r="1.5" fill={pal.colors.replyLineDot} />
           <Circle cx="2" cy="28" r="1.5" fill={pal.colors.replyLineDot} />
+          <Circle cx="2" cy="34" r="1.5" fill={pal.colors.replyLineDot} />
         </Svg>
       </View>
-      <Text type="md" style={pal.link}>
+
+      <Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
         View full thread
       </Text>
     </Link>
@@ -100,13 +106,12 @@ function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) {
 
 const styles = StyleSheet.create({
   viewFullThread: {
-    paddingTop: 14,
-    paddingBottom: 6,
-    paddingLeft: 80,
+    flexDirection: 'row',
+    gap: 10,
+    paddingLeft: 18,
   },
   viewFullThreadDots: {
-    position: 'absolute',
-    left: 41,
-    top: 0,
+    width: 52,
+    alignItems: 'center',
   },
 })
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 946e0f2ab..771785ee9 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -1,7 +1,11 @@
 import * as React from 'react'
 import {StyleSheet, View} from 'react-native'
 import {observer} from 'mobx-react-lite'
-import {AppBskyActorDefs} from '@atproto/api'
+import {
+  AppBskyActorDefs,
+  moderateProfile,
+  ProfileModeration,
+} from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
 import {UserAvatar} from '../util/UserAvatar'
@@ -11,12 +15,12 @@ import {useStores} from 'state/index'
 import {FollowButton} from './FollowButton'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
-import {
-  getProfileViewBasicLabelInfo,
-  getProfileModeration,
-} from 'lib/labeling/helpers'
-import {ModerationBehaviorCode} from 'lib/labeling/types'
 import {makeProfileLink} from 'lib/routes/links'
+import {
+  describeModerationCause,
+  getProfileModerationCauses,
+  getModerationCauseKey,
+} from 'lib/moderation'
 
 export const ProfileCard = observer(
   ({
@@ -25,7 +29,6 @@ export const ProfileCard = observer(
     noBg,
     noBorder,
     followers,
-    overrideModeration,
     renderButton,
   }: {
     testID?: string
@@ -33,7 +36,6 @@ export const ProfileCard = observer(
     noBg?: boolean
     noBorder?: boolean
     followers?: AppBskyActorDefs.ProfileView[] | undefined
-    overrideModeration?: boolean
     renderButton?: (
       profile: AppBskyActorDefs.ProfileViewBasic,
     ) => React.ReactNode
@@ -41,18 +43,11 @@ export const ProfileCard = observer(
     const store = useStores()
     const pal = usePalette('default')
 
-    const moderation = getProfileModeration(
-      store,
-      getProfileViewBasicLabelInfo(profile),
+    const moderation = moderateProfile(
+      profile,
+      store.preferences.moderationOpts,
     )
 
-    if (
-      moderation.list.behavior === ModerationBehaviorCode.Hide &&
-      !overrideModeration
-    ) {
-      return null
-    }
-
     return (
       <Link
         testID={testID}
@@ -82,20 +77,17 @@ export const ProfileCard = observer(
               lineHeight={1.2}>
               {sanitizeDisplayName(
                 profile.displayName || sanitizeHandle(profile.handle),
+                moderation.profile,
               )}
             </Text>
             <Text type="md" style={[pal.textLight]} numberOfLines={1}>
               {sanitizeHandle(profile.handle, '@')}
             </Text>
-            {!!profile.viewer?.followedBy && (
-              <View style={s.flexRow}>
-                <View style={[s.mt5, pal.btn, styles.pill]}>
-                  <Text type="xs" style={pal.text}>
-                    Follows You
-                  </Text>
-                </View>
-              </View>
-            )}
+            <ProfileCardPills
+              followedBy={!!profile.viewer?.followedBy}
+              moderation={moderation}
+            />
+            {!!profile.viewer?.followedBy && <View style={s.flexRow} />}
           </View>
           {renderButton ? (
             <View style={styles.layoutButton}>{renderButton(profile)}</View>
@@ -114,6 +106,46 @@ export const ProfileCard = observer(
   },
 )
 
+function ProfileCardPills({
+  followedBy,
+  moderation,
+}: {
+  followedBy: boolean
+  moderation: ProfileModeration
+}) {
+  const pal = usePalette('default')
+
+  const causes = getProfileModerationCauses(moderation)
+  if (!followedBy && !causes.length) {
+    return null
+  }
+
+  return (
+    <View style={styles.pills}>
+      {followedBy && (
+        <View style={[s.mt5, pal.btn, styles.pill]}>
+          <Text type="xs" style={pal.text}>
+            Follows You
+          </Text>
+        </View>
+      )}
+      {causes.map(cause => {
+        const desc = describeModerationCause(cause, 'account')
+        return (
+          <View
+            style={[s.mt5, pal.btn, styles.pill]}
+            key={getModerationCauseKey(cause)}>
+            <Text type="xs" style={pal.text}>
+              {cause?.type === 'label' ? 'âš ' : ''}
+              {desc.name}
+            </Text>
+          </View>
+        )
+      })}
+    </View>
+  )
+}
+
 const FollowersList = observer(
   ({followers}: {followers?: AppBskyActorDefs.ProfileView[] | undefined}) => {
     const store = useStores()
@@ -125,9 +157,9 @@ const FollowersList = observer(
     const followersWithMods = followers
       .map(f => ({
         f,
-        mod: getProfileModeration(store, getProfileViewBasicLabelInfo(f)),
+        mod: moderateProfile(f, store.preferences.moderationOpts),
       }))
-      .filter(({mod}) => mod.list.behavior !== ModerationBehaviorCode.Hide)
+      .filter(({mod}) => !mod.account.filter)
 
     return (
       <View style={styles.followedBy}>
@@ -218,6 +250,12 @@ const styles = StyleSheet.create({
     paddingRight: 10,
     paddingBottom: 10,
   },
+  pills: {
+    flexDirection: 'row',
+    flexWrap: 'wrap',
+    columnGap: 6,
+    rowGap: 2,
+  },
   pill: {
     borderRadius: 4,
     paddingHorizontal: 6,
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index a372f0d81..dd3fb530e 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -21,15 +21,13 @@ import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
 import {Text} from '../util/text/Text'
 import {ThemedText} from '../util/text/ThemedText'
-import {TextLink} from '../util/Link'
 import {RichText} from '../util/text/RichText'
 import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
-import {ProfileHeaderWarnings} from '../util/moderation/ProfileHeaderWarnings'
+import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
-import {listUriToHref} from 'lib/strings/url-helpers'
 import {isDesktopWeb, isNative} from 'platform/detection'
 import {FollowState} from 'state/models/cache/my-follows'
 import {shareUrl} from 'lib/sharing'
@@ -116,7 +114,10 @@ const ProfileHeaderLoaded = observer(
     }, [navigation])
 
     const onPressAvi = React.useCallback(() => {
-      if (view.avatar) {
+      if (
+        view.avatar &&
+        !(view.moderation.avatar.blur && view.moderation.avatar.noOverride)
+      ) {
         store.shell.openLightbox(new ProfileImageLightbox(view))
       }
     }, [store, view])
@@ -244,7 +245,7 @@ const ProfileHeaderLoaded = observer(
     const onPressReportAccount = React.useCallback(() => {
       track('ProfileHeader:ReportAccountButtonClicked')
       store.shell.openModal({
-        name: 'report-account',
+        name: 'report',
         did: view.did,
       })
     }, [track, store, view])
@@ -434,6 +435,7 @@ const ProfileHeaderLoaded = observer(
               style={[pal.text, styles.title]}>
               {sanitizeDisplayName(
                 view.displayName || sanitizeHandle(view.handle),
+                view.moderation.profile,
               )}
             </Text>
           </View>
@@ -494,7 +496,9 @@ const ProfileHeaderLoaded = observer(
                   </Text>
                 </Text>
               </View>
-              {view.descriptionRichText ? (
+              {view.description &&
+              view.descriptionRichText &&
+              !view.moderation.profile.blur ? (
                 <RichText
                   testID="profileHeaderDescription"
                   style={[styles.description, pal.text]}
@@ -504,52 +508,7 @@ const ProfileHeaderLoaded = observer(
               ) : undefined}
             </>
           )}
-          <ProfileHeaderWarnings moderation={view.moderation.view} />
-          <View style={styles.moderationLines}>
-            {view.viewer.blocking ? (
-              <View
-                testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text]} />
-                <Text type="lg-medium" style={pal.text}>
-                  Account blocked
-                </Text>
-              </View>
-            ) : view.viewer.muted ? (
-              <View
-                testID="profileHeaderMutedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon
-                  icon={['far', 'eye-slash']}
-                  style={[pal.text]}
-                />
-                <Text type="lg-medium" style={pal.text}>
-                  Account muted{' '}
-                  {view.viewer.mutedByList && (
-                    <Text type="lg-medium" style={pal.text}>
-                      by{' '}
-                      <TextLink
-                        type="lg-medium"
-                        style={pal.link}
-                        href={listUriToHref(view.viewer.mutedByList.uri)}
-                        text={view.viewer.mutedByList.name}
-                      />
-                    </Text>
-                  )}
-                </Text>
-              </View>
-            ) : undefined}
-            {view.viewer.blockedBy && (
-              <View
-                testID="profileHeaderBlockedNotice"
-                style={[styles.moderationNotice, pal.viewLight]}>
-                <FontAwesomeIcon icon="ban" style={[pal.text]} />
-                <Text type="lg-medium" style={pal.text}>
-                  This account has blocked you
-                </Text>
-              </View>
-            )}
-          </View>
+          <ProfileHeaderAlerts moderation={view.moderation} />
         </View>
         {!isDesktopWeb && !hideBackButton && (
           <TouchableWithoutFeedback
@@ -693,19 +652,6 @@ const styles = StyleSheet.create({
     paddingVertical: 2,
   },
 
-  moderationLines: {
-    gap: 6,
-  },
-
-  moderationNotice: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 8,
-    paddingHorizontal: 16,
-    paddingVertical: 14,
-    gap: 8,
-  },
-
   br40: {borderRadius: 40},
   br50: {borderRadius: 50},
 })
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 2ce499765..bf21ff0d1 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -91,7 +91,7 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
 const styles = StyleSheet.create({
   metaOneLine: {
     flexDirection: 'row',
-    alignItems: 'baseline',
+    alignItems: isAndroid ? 'center' : 'baseline',
     paddingBottom: 2,
     gap: 4,
   },
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index d999ffb31..0f34f75aa 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
 import Svg, {Circle, Rect, Path} from 'react-native-svg'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {HighPriorityImage} from 'view/com/util/images/Image'
+import {ModerationUI} from '@atproto/api'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -13,7 +14,6 @@ import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {AvatarModeration} from 'lib/labeling/types'
 import {UserPreviewLink} from './UserPreviewLink'
 import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
 
@@ -23,7 +23,7 @@ interface BaseUserAvatarProps {
   type?: Type
   size: number
   avatar?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
 }
 
 interface UserAvatarProps extends BaseUserAvatarProps {
@@ -213,20 +213,20 @@ export function UserAvatar({
     ],
   )
 
-  const warning = useMemo(() => {
-    if (!moderation?.warn) {
+  const alert = useMemo(() => {
+    if (!moderation?.alert) {
       return null
     }
     return (
-      <View style={[styles.warningIconContainer, pal.view]}>
+      <View style={[styles.alertIconContainer, pal.view]}>
         <FontAwesomeIcon
           icon="exclamation-circle"
-          style={styles.warningIcon}
+          style={styles.alertIcon}
           size={Math.floor(size / 3)}
         />
       </View>
     )
-  }, [moderation?.warn, size, pal])
+  }, [moderation?.alert, size, pal])
 
   // onSelectNewAvatar is only passed as prop on the EditProfile component
   return onSelectNewAvatar ? (
@@ -259,12 +259,12 @@ export function UserAvatar({
         source={{uri: avatar}}
         blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
       />
-      {warning}
+      {alert}
     </View>
   ) : (
     <View style={{width: size, height: size}}>
       <DefaultAvatar type={type} size={size} />
-      {warning}
+      {alert}
     </View>
   )
 }
@@ -289,13 +289,13 @@ const styles = StyleSheet.create({
     justifyContent: 'center',
     backgroundColor: colors.gray5,
   },
-  warningIconContainer: {
+  alertIconContainer: {
     position: 'absolute',
     right: 0,
     bottom: 0,
     borderRadius: 100,
   },
-  warningIcon: {
+  alertIcon: {
     color: colors.red3,
   },
 })
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index b7e91b5dd..7c5c583c2 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,6 +1,7 @@
 import React, {useMemo} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
 import {colors} from 'lib/styles'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
@@ -10,7 +11,6 @@ import {
   useCameraPermission,
 } from 'lib/hooks/usePermissions'
 import {usePalette} from 'lib/hooks/usePalette'
-import {AvatarModeration} from 'lib/labeling/types'
 import {isWeb, isAndroid} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
@@ -21,7 +21,7 @@ export function UserBanner({
   onSelectNewBanner,
 }: {
   banner?: string | null
-  moderation?: AvatarModeration
+  moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
 }) {
   const store = useStores()
diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx
index 7eedbc2d4..f43f9e80b 100644
--- a/src/view/com/util/UserPreviewLink.tsx
+++ b/src/view/com/util/UserPreviewLink.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {Pressable, StyleProp, ViewStyle} from 'react-native'
 import {useStores} from 'state/index'
 import {Link} from './Link'
-import {isDesktopWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 import {makeProfileLink} from 'lib/routes/links'
 
 interface UserPreviewLinkProps {
@@ -15,7 +15,7 @@ export function UserPreviewLink(
 ) {
   const store = useStores()
 
-  if (isDesktopWeb) {
+  if (isWeb) {
     return (
       <Link
         href={makeProfileLink(props)}
diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx
index e2f47ba89..a25ca4d8e 100644
--- a/src/view/com/util/ViewSelector.tsx
+++ b/src/view/com/util/ViewSelector.tsx
@@ -1,5 +1,11 @@
 import React, {useEffect, useState} from 'react'
-import {Pressable, RefreshControl, StyleSheet, View} from 'react-native'
+import {
+  Pressable,
+  RefreshControl,
+  StyleSheet,
+  View,
+  ScrollView,
+} from 'react-native'
 import {FlatList} from './Views'
 import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
@@ -140,6 +146,8 @@ export function Selector({
   items: string[]
   onSelect?: (index: number) => void
 }) {
+  const [height, setHeight] = useState(0)
+
   const pal = usePalette('default')
   const borderColor = useColorSchemeStyle(
     {borderColor: colors.black},
@@ -151,37 +159,56 @@ export function Selector({
   }
 
   return (
-    <View style={[pal.view, styles.outer]}>
-      {items.map((item, i) => {
-        const selected = i === selectedIndex
-        return (
-          <Pressable
-            testID={`selector-${i}`}
-            key={item}
-            onPress={() => onPressItem(i)}
-            accessibilityLabel={item}
-            accessibilityHint={`Selects ${item}`}
-            // TODO: Modify the component API such that lint fails
-            // at the invocation site as well
-          >
-            <View
-              style={[
-                styles.item,
-                selected && styles.itemSelected,
-                borderColor,
-              ]}>
-              <Text
-                style={
-                  selected
-                    ? [styles.labelSelected, pal.text]
-                    : [styles.label, pal.textLight]
-                }>
-                {item}
-              </Text>
-            </View>
-          </Pressable>
-        )
-      })}
+    <View
+      style={{
+        width: '100%',
+        position: 'relative',
+        overflow: 'hidden',
+        height,
+        backgroundColor: pal.colors.background,
+      }}>
+      <ScrollView
+        horizontal
+        showsHorizontalScrollIndicator={false}
+        style={{position: 'absolute'}}>
+        <View
+          style={[pal.view, styles.outer]}
+          onLayout={e => {
+            const {height} = e.nativeEvent.layout
+            setHeight(height || 60)
+          }}>
+          {items.map((item, i) => {
+            const selected = i === selectedIndex
+            return (
+              <Pressable
+                testID={`selector-${i}`}
+                key={item}
+                onPress={() => onPressItem(i)}
+                accessibilityLabel={item}
+                accessibilityHint={`Selects ${item}`}
+                // TODO: Modify the component API such that lint fails
+                // at the invocation site as well
+              >
+                <View
+                  style={[
+                    styles.item,
+                    selected && styles.itemSelected,
+                    borderColor,
+                  ]}>
+                  <Text
+                    style={
+                      selected
+                        ? [styles.labelSelected, pal.text]
+                        : [styles.label, pal.textLight]
+                    }>
+                    {item}
+                  </Text>
+                </View>
+              </Pressable>
+            )
+          })}
+        </View>
+      </ScrollView>
     </View>
   )
 }
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
index 9e6fcaa44..082285064 100644
--- a/src/view/com/util/forms/NativeDropdown.tsx
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -60,7 +60,6 @@ export const DropdownMenuTrigger = DropdownMenu.create(
                 icon="ellipsis"
                 size={20}
                 color={defaultCtrlColor}
-                style={styles.ellipsis}
               />
             )}
           </View>
@@ -252,9 +251,6 @@ const styles = StyleSheet.create({
     height: 1,
     marginVertical: 4,
   },
-  ellipsis: {
-    padding: isWeb ? 0 : 10,
-  },
   content: {
     backgroundColor: '#f0f0f0',
     borderRadius: 8,
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 27a1f20d0..969deb3ac 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,6 +1,9 @@
 import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useStores} from 'state/index'
+import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
 import {
   NativeDropdown,
@@ -19,6 +22,7 @@ export function PostDropdownBtn({
   onOpenTranslate,
   onToggleThreadMute,
   onDeletePost,
+  style,
 }: {
   testID: string
   itemUri: string
@@ -31,8 +35,11 @@ export function PostDropdownBtn({
   onOpenTranslate: () => void
   onToggleThreadMute: () => void
   onDeletePost: () => void
+  style?: StyleProp<ViewStyle>
 }) {
   const store = useStores()
+  const theme = useTheme()
+  const defaultCtrlColor = theme.palette.default.postCtrl
 
   const dropdownItems: NativeDropdownItem[] = [
     {
@@ -102,9 +109,9 @@ export function PostDropdownBtn({
       label: 'Report post',
       onPress() {
         store.shell.openModal({
-          name: 'report-post',
-          postUri: itemUri,
-          postCid: itemCid,
+          name: 'report',
+          uri: itemUri,
+          cid: itemCid,
         })
       },
       testID: 'postDropdownReportBtn',
@@ -146,8 +153,11 @@ export function PostDropdownBtn({
         testID={testID}
         items={dropdownItems}
         accessibilityLabel="More post options"
-        accessibilityHint=""
-      />
+        accessibilityHint="">
+        <View style={style}>
+          <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
+        </View>
+      </NativeDropdown>
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx
index 503c49b2f..4b494264e 100644
--- a/src/view/com/util/forms/SelectableBtn.tsx
+++ b/src/view/com/util/forms/SelectableBtn.tsx
@@ -5,6 +5,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {isDesktopWeb} from 'platform/detection'
 
 interface SelectableBtnProps {
+  testID?: string
   selected: boolean
   label: string
   left?: boolean
@@ -15,6 +16,7 @@ interface SelectableBtnProps {
 }
 
 export function SelectableBtn({
+  testID,
   selected,
   label,
   left,
@@ -25,12 +27,15 @@ export function SelectableBtn({
 }: SelectableBtnProps) {
   const pal = usePalette('default')
   const palPrimary = usePalette('inverted')
+  const needsWidthStyles = !style || !('width' in style || 'flex' in style)
   return (
     <Pressable
+      testID={testID}
       style={[
-        styles.selectableBtn,
-        left && styles.selectableBtnLeft,
-        right && styles.selectableBtnRight,
+        styles.btn,
+        needsWidthStyles && styles.btnWidth,
+        left && styles.btnLeft,
+        right && styles.btnRight,
         pal.border,
         selected ? palPrimary.view : pal.view,
         style,
@@ -45,9 +50,7 @@ export function SelectableBtn({
 }
 
 const styles = StyleSheet.create({
-  selectableBtn: {
-    flex: isDesktopWeb ? undefined : 1,
-    width: isDesktopWeb ? 100 : undefined,
+  btn: {
     flexDirection: 'row',
     justifyContent: 'center',
     borderWidth: 1,
@@ -55,12 +58,16 @@ const styles = StyleSheet.create({
     paddingHorizontal: 10,
     paddingVertical: 10,
   },
-  selectableBtnLeft: {
+  btnWidth: {
+    flex: isDesktopWeb ? undefined : 1,
+    width: isDesktopWeb ? 100 : undefined,
+  },
+  btnLeft: {
     borderTopLeftRadius: 8,
     borderBottomLeftRadius: 8,
     borderLeftWidth: 1,
   },
-  selectableBtnRight: {
+  btnRight: {
     borderTopRightRadius: 8,
     borderBottomRightRadius: 8,
   },
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index ac5c8395d..853f7840c 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -1,36 +1,32 @@
 import React from 'react'
 import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
+import {ModerationUI} from '@atproto/api'
 import {Text} from '../text/Text'
-import {addStyle} from 'lib/styles'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
+import {ShieldExclamation} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 export function ContentHider({
   testID,
   moderation,
+  ignoreMute,
   style,
-  containerStyle,
+  childContainerStyle,
   children,
 }: React.PropsWithChildren<{
   testID?: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
+  ignoreMute?: boolean
   style?: StyleProp<ViewStyle>
-  containerStyle?: StyleProp<ViewStyle>
+  childContainerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const onPressShow = React.useCallback(() => {
-    setOverride(true)
-  }, [setOverride])
-  const onPressHide = React.useCallback(() => {
-    setOverride(false)
-  }, [setOverride])
 
-  if (
-    moderation.behavior === ModerationBehaviorCode.Show ||
-    moderation.behavior === ModerationBehaviorCode.Warn ||
-    moderation.behavior === ModerationBehaviorCode.WarnImages
-  ) {
+  if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -38,73 +34,72 @@ export function ContentHider({
     )
   }
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <View style={[styles.container, pal.view, pal.border, containerStyle]}>
+    <View testID={testID} style={style}>
       <Pressable
-        onPress={override ? onPressHide : onPressShow}
-        accessibilityLabel={override ? 'Hide post' : 'Show post'}
-        // TODO: The text labelling should be split up so controls have unique roles
-        accessibilityHint={
-          override
-            ? 'Re-hide post'
-            : 'Shows post hidden based on your moderation settings'
-        }
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          } else {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
         style={[
-          styles.description,
-          pal.viewLight,
-          override && styles.descriptionOpen,
+          styles.cover,
+          moderation.noOverride
+            ? {borderWidth: 1, borderColor: pal.colors.borderDark}
+            : pal.viewLight,
         ]}>
-        <Text type="md" style={pal.textLight}>
-          {moderation.reason || 'Content warning'}
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <ShieldExclamation size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
         </Text>
-        <View style={styles.showBtn}>
-          <Text type="md-medium" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </View>
-      </Pressable>
-      {override && (
-        <View style={[styles.childrenContainer, pal.border]}>
-          <View testID={testID} style={addStyle(style, styles.child)}>
-            {children}
+        {!moderation.noOverride && (
+          <View style={styles.showBtn}>
+            <Text type="xl" style={pal.link}>
+              {override ? 'Hide' : 'Show'}
+            </Text>
           </View>
-        </View>
-      )}
+        )}
+      </Pressable>
+      {override && <View style={childContainerStyle}>{children}</View>}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    marginBottom: 10,
-    borderWidth: 1,
-    borderRadius: 12,
-  },
-  description: {
+  cover: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 4,
     paddingVertical: 14,
     paddingLeft: 14,
-    paddingRight: 18,
-    borderRadius: 12,
-  },
-  descriptionOpen: {
-    borderBottomLeftRadius: 0,
-    borderBottomRightRadius: 0,
-  },
-  icon: {
-    marginRight: 10,
+    paddingRight: isDesktopWeb ? 18 : 22,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
-  childrenContainer: {
-    paddingHorizontal: 12,
-    paddingTop: 8,
-  },
-  child: {},
 })
diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx
deleted file mode 100644
index 40c9d0a21..000000000
--- a/src/view/com/util/moderation/ImageHider.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Text} from '../text/Text'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-import {isDesktopWeb} from 'platform/detection'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome'
-
-export function ImageHider({
-  testID,
-  moderation,
-  style,
-  children,
-}: React.PropsWithChildren<{
-  testID?: string
-  moderation: ModerationBehavior
-  style?: StyleProp<ViewStyle>
-}>) {
-  const pal = usePalette('default')
-  const [override, setOverride] = React.useState(false)
-  const onPressToggle = React.useCallback(() => {
-    setOverride(v => !v)
-  }, [setOverride])
-
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior !== ModerationBehaviorCode.WarnImages) {
-    return (
-      <View testID={testID} style={style}>
-        {children}
-      </View>
-    )
-  }
-
-  return (
-    <View testID={testID} style={style}>
-      <View style={[styles.cover, pal.viewLight]}>
-        <Pressable
-          onPress={onPressToggle}
-          style={[styles.toggleBtn]}
-          accessibilityLabel="Show image"
-          accessibilityHint="">
-          <FontAwesomeIcon
-            icon={override ? 'eye' : ['far', 'eye-slash']}
-            size={24}
-            style={pal.text as FontAwesomeIconStyle}
-          />
-          <Text type="lg" style={pal.text}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <View style={styles.flex1} />
-          <Text type="xl-bold" style={pal.link}>
-            {override ? 'Hide' : 'Show'}
-          </Text>
-        </Pressable>
-      </View>
-      {override && children}
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  cover: {
-    borderRadius: 8,
-    marginTop: 4,
-  },
-  toggleBtn: {
-    flexDirection: 'row',
-    gap: 8,
-    alignItems: 'center',
-    paddingHorizontal: isDesktopWeb ? 24 : 20,
-    paddingVertical: isDesktopWeb ? 20 : 18,
-  },
-  flex1: {
-    flex: 1,
-  },
-})
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
new file mode 100644
index 000000000..8a6cbbb85
--- /dev/null
+++ b/src/view/com/util/moderation/PostAlerts.tsx
@@ -0,0 +1,68 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ShieldExclamation} from 'lib/icons'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function PostAlerts({
+  moderation,
+  includeMute,
+  style,
+}: {
+  moderation: ModerationUI
+  includeMute?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const shouldAlert =
+    !!moderation.cause &&
+    (moderation.alert ||
+      (includeMute && moderation.blur && moderation.cause?.type === 'muted'))
+  if (!shouldAlert) {
+    return null
+  }
+
+  const desc = describeModerationCause(moderation.cause, 'content')
+  return (
+    <Pressable
+      onPress={() => {
+        store.shell.openModal({
+          name: 'moderation-details',
+          context: 'content',
+          moderation,
+        })
+      }}
+      accessibilityRole="button"
+      accessibilityLabel="Learn more about this warning"
+      accessibilityHint=""
+      style={[styles.container, pal.viewLight, style]}>
+      <ShieldExclamation style={pal.text} size={16} />
+      <Text type="lg" style={pal.text}>
+        {desc.name}
+      </Text>
+      <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+        Learn More
+      </Text>
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    paddingVertical: 8,
+    paddingLeft: 14,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index f2b6dbddd..2a52561d4 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -1,17 +1,20 @@
 import React, {ComponentProps} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {StyleSheet, Pressable, View} from 'react-native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {Link} from '../Link'
 import {Text} from '../text/Text'
 import {addStyle} from 'lib/styles'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {ShieldExclamation} from 'lib/icons'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
 
 interface Props extends ComponentProps<typeof Link> {
   // testID?: string
   // href?: string
   // style: StyleProp<ViewStyle>
-  moderation: ModerationBehavior
+  moderation: ModerationUI
 }
 
 export function PostHider({
@@ -22,60 +25,71 @@ export function PostHider({
   children,
   ...props
 }: Props) {
+  const store = useStores()
   const pal = usePalette('default')
   const [override, setOverride] = React.useState(false)
-  const bg = override ? pal.viewLight : pal.view
 
-  if (moderation.behavior === ModerationBehaviorCode.Hide) {
-    return null
-  }
-
-  if (moderation.behavior === ModerationBehaviorCode.Warn) {
+  if (!moderation.blur) {
     return (
-      <>
-        <View style={[styles.description, bg, pal.border]}>
-          <FontAwesomeIcon
-            icon={['far', 'eye-slash']}
-            style={[styles.icon, pal.text]}
-          />
-          <Text type="md" style={pal.textLight}>
-            {moderation.reason || 'Content warning'}
-          </Text>
-          <TouchableOpacity
-            style={styles.showBtn}
-            onPress={() => setOverride(v => !v)}
-            accessibilityRole="button">
-            <Text type="md" style={pal.link}>
-              {override ? 'Hide' : 'Show'} post
-            </Text>
-          </TouchableOpacity>
-        </View>
-        {override && (
-          <View style={[styles.childrenContainer, pal.border, bg]}>
-            <Link
-              testID={testID}
-              style={addStyle(style, styles.child)}
-              href={href}
-              noFeedback>
-              {children}
-            </Link>
-          </View>
-        )}
-      </>
+      <Link
+        testID={testID}
+        style={style}
+        href={href}
+        noFeedback
+        accessible={false}
+        {...props}>
+        {children}
+      </Link>
     )
   }
 
-  // NOTE: any further label enforcement should occur in ContentContainer
+  const desc = describeModerationCause(moderation.cause, 'content')
   return (
-    <Link
-      testID={testID}
-      style={style}
-      href={href}
-      noFeedback
-      accessible={false}
-      {...props}>
-      {children}
-    </Link>
+    <>
+      <Pressable
+        onPress={() => {
+          if (!moderation.noOverride) {
+            setOverride(v => !v)
+          }
+        }}
+        accessibilityRole="button"
+        accessibilityHint={override ? 'Hide the content' : 'Show the content'}
+        accessibilityLabel=""
+        style={[styles.description, pal.viewLight]}>
+        <Pressable
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'content',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <ShieldExclamation size={18} style={pal.text} />
+        </Pressable>
+        <Text type="lg" style={pal.text}>
+          {desc.name}
+        </Text>
+        {!moderation.noOverride && (
+          <Text type="xl" style={[styles.showBtn, pal.link]}>
+            {override ? 'Hide' : 'Show'}
+          </Text>
+        )}
+      </Pressable>
+      {override && (
+        <View style={[styles.childrenContainer, pal.border, pal.viewLight]}>
+          <Link
+            testID={testID}
+            style={addStyle(style, styles.child)}
+            href={href}
+            noFeedback>
+            {children}
+          </Link>
+        </View>
+      )}
+    </>
   )
 }
 
@@ -83,22 +97,23 @@ const styles = StyleSheet.create({
   description: {
     flexDirection: 'row',
     alignItems: 'center',
+    gap: 4,
     paddingVertical: 14,
-    paddingHorizontal: 18,
-    borderTopWidth: 1,
-  },
-  icon: {
-    marginRight: 10,
+    paddingLeft: 18,
+    paddingRight: isDesktopWeb ? 18 : 22,
+    marginTop: 1,
   },
   showBtn: {
     marginLeft: 'auto',
+    alignSelf: 'center',
   },
   childrenContainer: {
-    paddingHorizontal: 6,
+    paddingHorizontal: 4,
     paddingBottom: 6,
   },
   child: {
-    borderWidth: 1,
-    borderRadius: 12,
+    borderWidth: 0,
+    borderTopWidth: 0,
+    borderRadius: 8,
   },
 })
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 000000000..b7781e06d
--- /dev/null
+++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ProfileModeration} from '@atproto/api'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ShieldExclamation} from 'lib/icons'
+import {
+  describeModerationCause,
+  getProfileModerationCauses,
+} from 'lib/moderation'
+import {useStores} from 'state/index'
+
+export function ProfileHeaderAlerts({
+  moderation,
+  style,
+}: {
+  moderation: ProfileModeration
+  style?: StyleProp<ViewStyle>
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+
+  const causes = getProfileModerationCauses(moderation)
+  if (!causes.length) {
+    return null
+  }
+
+  return (
+    <View style={styles.grid}>
+      {causes.map(cause => {
+        const desc = describeModerationCause(cause, 'account')
+        return (
+          <Pressable
+            testID="profileHeaderAlert"
+            key={desc.name}
+            onPress={() => {
+              store.shell.openModal({
+                name: 'moderation-details',
+                context: 'content',
+                moderation: {cause},
+              })
+            }}
+            accessibilityRole="button"
+            accessibilityLabel="Learn more about this warning"
+            accessibilityHint=""
+            style={[styles.container, pal.viewLight, style]}>
+            <ShieldExclamation style={pal.text} size={24} />
+            <Text type="lg" style={pal.text}>
+              {desc.name}
+            </Text>
+            <Text type="lg" style={[pal.link, styles.learnMoreBtn]}>
+              Learn More
+            </Text>
+          </Pressable>
+        )
+      })}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  grid: {
+    gap: 4,
+  },
+  container: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+    paddingVertical: 12,
+    paddingHorizontal: 16,
+    borderRadius: 8,
+  },
+  learnMoreBtn: {
+    marginLeft: 'auto',
+  },
+})
diff --git a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx b/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
deleted file mode 100644
index 7a1a8e295..000000000
--- a/src/view/com/util/moderation/ProfileHeaderWarnings.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ModerationBehavior, ModerationBehaviorCode} from 'lib/labeling/types'
-
-export function ProfileHeaderWarnings({
-  moderation,
-}: {
-  moderation: ModerationBehavior
-}) {
-  const palErr = usePalette('error')
-  if (moderation.behavior === ModerationBehaviorCode.Show) {
-    return null
-  }
-  return (
-    <View style={[styles.container, palErr.border, palErr.view]}>
-      <FontAwesomeIcon
-        icon="circle-exclamation"
-        style={palErr.text as FontAwesomeIconStyle}
-        size={20}
-      />
-      <Text style={palErr.text}>
-        This account has been flagged: {moderation.reason}
-      </Text>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 10,
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 10,
-    paddingVertical: 8,
-  },
-})
diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
index 2e7b07e1a..b76b1101c 100644
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ b/src/view/com/util/moderation/ScreenHider.tsx
@@ -1,16 +1,24 @@
 import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  TouchableWithoutFeedback,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
 } from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
 import {usePalette} from 'lib/hooks/usePalette'
 import {NavigationProp} from 'lib/routes/types'
 import {Text} from '../text/Text'
 import {Button} from '../forms/Button'
 import {isDesktopWeb} from 'platform/detection'
-import {ModerationBehaviorCode, ModerationBehavior} from 'lib/labeling/types'
+import {describeModerationCause} from 'lib/moderation'
+import {useStores} from 'state/index'
 
 export function ScreenHider({
   testID,
@@ -22,24 +30,17 @@ export function ScreenHider({
 }: React.PropsWithChildren<{
   testID?: string
   screenDescription: string
-  moderation: ModerationBehavior
+  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
   containerStyle?: StyleProp<ViewStyle>
 }>) {
+  const store = useStores()
   const pal = usePalette('default')
   const palInverted = usePalette('inverted')
   const [override, setOverride] = React.useState(false)
   const navigation = useNavigation<NavigationProp>()
 
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
-  if (moderation.behavior !== ModerationBehaviorCode.Hide || override) {
+  if (!moderation.blur || override) {
     return (
       <View testID={testID} style={style}>
         {children}
@@ -47,6 +48,7 @@ export function ScreenHider({
     )
   }
 
+  const desc = describeModerationCause(moderation.cause, 'account')
   return (
     <View style={[styles.container, pal.view, containerStyle]}>
       <View style={styles.iconContainer}>
@@ -63,11 +65,38 @@ export function ScreenHider({
       </Text>
       <Text type="2xl" style={[styles.description, pal.textLight]}>
         This {screenDescription} has been flagged:{' '}
-        {moderation.reason || 'Content warning'}
+        <Text type="2xl-medium" style={pal.text}>
+          {desc.name}
+        </Text>
+        .{' '}
+        <TouchableWithoutFeedback
+          onPress={() => {
+            store.shell.openModal({
+              name: 'moderation-details',
+              context: 'account',
+              moderation,
+            })
+          }}
+          accessibilityRole="button"
+          accessibilityLabel="Learn more about this warning"
+          accessibilityHint="">
+          <Text type="2xl" style={pal.link}>
+            Learn More
+          </Text>
+        </TouchableWithoutFeedback>
       </Text>
       {!isDesktopWeb && <View style={styles.spacer} />}
       <View style={styles.btnContainer}>
-        <Button type="inverted" onPress={onPressBack} style={styles.btn}>
+        <Button
+          type="inverted"
+          onPress={() => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Home')
+            }
+          }}
+          style={styles.btn}>
           <Text type="button-lg" style={pal.textInverted}>
             Go back
           </Text>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 672e02693..c71100df0 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,11 +6,6 @@ import {
   View,
   ViewStyle,
 } from 'react-native'
-// DISABLED see #135
-// import {
-//   TriggerableAnimated,
-//   TriggerableAnimatedRef,
-// } from './anim/TriggerableAnimated'
 import {Text} from '../text/Text'
 import {PostDropdownBtn} from '../forms/PostDropdownBtn'
 import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
@@ -20,7 +15,6 @@ import {useTheme} from 'lib/ThemeContext'
 import {useStores} from 'state/index'
 import {RepostButton} from './RepostButton'
 import {Haptics} from 'lib/haptics'
-import {createHitslop} from 'lib/constants'
 
 interface PostCtrlsOpts {
   itemUri: string
@@ -53,44 +47,6 @@ interface PostCtrlsOpts {
   onDeletePost: () => void
 }
 
-const HITSLOP = createHitslop(5)
-
-// DISABLED see #135
-/*
-function ctrlAnimStart(interp: Animated.Value) {
-  return Animated.sequence([
-    Animated.timing(interp, {
-      toValue: 1,
-      duration: 250,
-      useNativeDriver: true,
-    }),
-    Animated.delay(50),
-    Animated.timing(interp, {
-      toValue: 0,
-      duration: 20,
-      useNativeDriver: true,
-    }),
-  ])
-}
-
-function ctrlAnimStyle(interp: Animated.Value) {
-  return {
-    transform: [
-      {
-        scale: interp.interpolate({
-          inputRange: [0, 1.0],
-          outputRange: [1.0, 4.0],
-        }),
-      },
-    ],
-    opacity: interp.interpolate({
-      inputRange: [0, 1.0],
-      outputRange: [1.0, 0.0],
-    }),
-  }
-}
-*/
-
 export function PostCtrls(opts: PostCtrlsOpts) {
   const store = useStores()
   const theme = useTheme()
@@ -100,22 +56,11 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     }),
     [theme],
   ) as StyleProp<ViewStyle>
-  // DISABLED see #135
-  // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
-  // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
   const onRepost = useCallback(() => {
     store.shell.closeModal()
     if (!opts.isReposted) {
       Haptics.default()
       opts.onPressToggleRepost().catch(_e => undefined)
-      // DISABLED see #135
-      // repostRef.current?.trigger(
-      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
-      //   async () => {
-      //     await opts.onPressToggleRepost().catch(_e => undefined)
-      //     setRepostMod(0)
-      //   },
-      // )
     } else {
       opts.onPressToggleRepost().catch(_e => undefined)
     }
@@ -146,18 +91,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     if (!opts.isLiked) {
       Haptics.default()
       await opts.onPressToggleLike().catch(_e => undefined)
-      // DISABLED see #135
-      // likeRef.current?.trigger(
-      //   {start: ctrlAnimStart, style: ctrlAnimStyle},
-      //   async () => {
-      //     await opts.onPressToggleLike().catch(_e => undefined)
-      //     setLikeMod(0)
-      //   },
-      // )
-      // setIsLikedPressed(false)
     } else {
       await opts.onPressToggleLike().catch(_e => undefined)
-      // setIsLikedPressed(false)
     }
   }
 
@@ -165,8 +100,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
     <View style={[styles.ctrls, opts.style]}>
       <TouchableOpacity
         testID="replyBtn"
-        style={styles.ctrl}
-        hitSlop={HITSLOP}
+        style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]}
         onPress={opts.onPressReply}
         accessibilityRole="button"
         accessibilityLabel={`Reply (${opts.replyCount} ${
@@ -187,8 +121,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
       <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} />
       <TouchableOpacity
         testID="likeBtn"
-        style={styles.ctrl}
-        hitSlop={HITSLOP}
+        style={[styles.ctrl, !opts.big && styles.ctrlPad]}
         onPress={onPressToggleLikeWrapper}
         accessibilityRole="button"
         accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${
@@ -232,6 +165,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           onOpenTranslate={opts.onOpenTranslate}
           onToggleThreadMute={opts.onToggleThreadMute}
           onDeletePost={opts.onDeletePost}
+          style={styles.ctrlPad}
         />
       )}
       {/* used for adding pad to the right side */}
@@ -248,8 +182,12 @@ const styles = StyleSheet.create({
   ctrl: {
     flexDirection: 'row',
     alignItems: 'center',
-    padding: 5,
-    margin: -5,
+  },
+  ctrlPad: {
+    paddingTop: 5,
+    paddingBottom: 5,
+    paddingLeft: 5,
+    paddingRight: 5,
   },
   ctrlIconLiked: {
     color: colors.like,
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 5fe62aefe..374d06515 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -6,9 +6,6 @@ import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
 import {pluralize} from 'lib/strings/helpers'
 import {useStores} from 'state/index'
-import {createHitslop} from 'lib/constants'
-
-const HITSLOP = createHitslop(5)
 
 interface Props {
   isReposted: boolean
@@ -47,9 +44,8 @@ export const RepostButton = ({
   return (
     <TouchableOpacity
       testID="repostBtn"
-      hitSlop={HITSLOP}
       onPress={onPressToggleRepostWrapper}
-      style={styles.control}
+      style={[styles.control, !big && styles.controlPad]}
       accessibilityRole="button"
       accessibilityLabel={`${
         isReposted ? 'Undo repost' : 'Repost'
@@ -83,8 +79,9 @@ const styles = StyleSheet.create({
   control: {
     flexDirection: 'row',
     alignItems: 'center',
+  },
+  controlPad: {
     padding: 5,
-    margin: -5,
   },
   reposted: {
     color: colors.green3,
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 4d2a3fcdd..eab6e2fef 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -52,6 +52,7 @@ export const RepostButton = ({
       <View
         style={[
           styles.control,
+          !big && styles.controlPad,
           (isReposted
             ? styles.reposted
             : defaultControlColor) as StyleProp<ViewStyle>,
@@ -77,6 +78,9 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     gap: 4,
   },
+  controlPad: {
+    padding: 5,
+  },
   reposted: {
     color: colors.green3,
   },
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index a4cbb3e29..81f1ca560 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -1,9 +1,11 @@
 import React from 'react'
+import {Image} from 'expo-image'
 import {Text} from '../text/Text'
-import {AutoSizedImage} from '../images/AutoSizedImage'
 import {StyleSheet, View} from 'react-native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {AppBskyEmbedExternal} from '@atproto/api'
+import {isDesktopWeb} from 'platform/detection'
+import {toNiceDomain} from 'lib/strings/url-helpers'
 
 export const ExternalLinkEmbed = ({
   link,
@@ -14,44 +16,71 @@ export const ExternalLinkEmbed = ({
 }) => {
   const pal = usePalette('default')
   return (
-    <>
+    <View style={styles.extContainer}>
       {link.thumb ? (
-        <AutoSizedImage uri={link.thumb} style={styles.extImage}>
+        <View style={styles.extImageContainer}>
+          <Image
+            style={styles.extImage}
+            source={{uri: link.thumb}}
+            accessibilityIgnoresInvertColors
+          />
           {imageChild}
-        </AutoSizedImage>
+        </View>
       ) : undefined}
       <View style={styles.extInner}>
-        <Text type="md-bold" numberOfLines={2} style={[pal.text]}>
-          {link.title || link.uri}
-        </Text>
         <Text
           type="sm"
           numberOfLines={1}
           style={[pal.textLight, styles.extUri]}>
-          {link.uri}
+          {toNiceDomain(link.uri)}
+        </Text>
+        <Text
+          type="lg-bold"
+          numberOfLines={isDesktopWeb ? 2 : 4}
+          style={[pal.text]}>
+          {link.title || link.uri}
         </Text>
         {link.description ? (
           <Text
-            type="sm"
-            numberOfLines={2}
+            type="md"
+            numberOfLines={isDesktopWeb ? 2 : 4}
             style={[pal.text, styles.extDescription]}>
             {link.description}
           </Text>
         ) : undefined}
       </View>
-    </>
+    </View>
   )
 }
 
 const styles = StyleSheet.create({
+  extContainer: {
+    flexDirection: isDesktopWeb ? 'row' : 'column',
+  },
   extInner: {
-    padding: 10,
+    paddingHorizontal: isDesktopWeb ? 14 : 10,
+    paddingTop: 8,
+    paddingBottom: 10,
+    flex: isDesktopWeb ? 1 : undefined,
   },
+  extImageContainer: isDesktopWeb
+    ? {
+        borderTopLeftRadius: 6,
+        borderBottomLeftRadius: 6,
+        width: 120,
+        aspectRatio: 1,
+        overflow: 'hidden',
+      }
+    : {
+        borderTopLeftRadius: 6,
+        borderTopRightRadius: 6,
+        width: '100%',
+        height: 200,
+        overflow: 'hidden',
+      },
   extImage: {
-    borderTopLeftRadius: 6,
-    borderTopRightRadius: 6,
     width: '100%',
-    maxHeight: 200,
+    height: 200,
   },
   extUri: {
     marginTop: 2,
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 4995562ac..f82b5b7df 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,6 +1,12 @@
 import React from 'react'
-import {StyleProp, StyleSheet, ViewStyle} from 'react-native'
-import {AppBskyEmbedImages, AppBskyEmbedRecordWithMedia} from '@atproto/api'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {
+  AppBskyEmbedRecord,
+  AppBskyFeedPost,
+  AppBskyEmbedImages,
+  AppBskyEmbedRecordWithMedia,
+  ModerationUI,
+} from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
 import {Link} from '../Link'
@@ -8,13 +14,68 @@ import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/models/ui/shell'
 import {PostEmbeds} from '.'
+import {PostAlerts} from '../moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
+import {InfoCircleIcon} from 'lib/icons'
+
+export function MaybeQuoteEmbed({
+  embed,
+  moderation,
+  style,
+}: {
+  embed: AppBskyEmbedRecord.View
+  moderation: ModerationUI
+  style?: StyleProp<ViewStyle>
+}) {
+  const pal = usePalette('default')
+  if (
+    AppBskyEmbedRecord.isViewRecord(embed.record) &&
+    AppBskyFeedPost.isRecord(embed.record.value) &&
+    AppBskyFeedPost.validateRecord(embed.record.value).success
+  ) {
+    return (
+      <QuoteEmbed
+        quote={{
+          author: embed.record.author,
+          cid: embed.record.cid,
+          uri: embed.record.uri,
+          indexedAt: embed.record.indexedAt,
+          text: embed.record.value.text,
+          embeds: embed.record.embeds,
+        }}
+        moderation={moderation}
+        style={style}
+      />
+    )
+  } else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Blocked
+        </Text>
+      </View>
+    )
+  } else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
+    return (
+      <View style={[styles.errorContainer, pal.borderDark]}>
+        <InfoCircleIcon size={18} style={pal.text} />
+        <Text type="lg" style={pal.text}>
+          Deleted
+        </Text>
+      </View>
+    )
+  }
+  return null
+}
 
 export function QuoteEmbed({
   quote,
+  moderation,
   style,
 }: {
   quote: ComposerOptsQuote
+  moderation?: ModerationUI
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -46,16 +107,19 @@ export function QuoteEmbed({
         postHref={itemHref}
         timestamp={quote.indexedAt}
       />
+      {moderation ? (
+        <PostAlerts moderation={moderation} style={styles.alert} />
+      ) : null}
       {!isEmpty ? (
         <Text type="post-text" style={pal.text} numberOfLines={6}>
           {quote.text}
         </Text>
       ) : null}
       {AppBskyEmbedImages.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed} />
+        <PostEmbeds embed={imagesEmbed} moderation={{}} />
       )}
       {AppBskyEmbedRecordWithMedia.isView(imagesEmbed) && (
-        <PostEmbeds embed={imagesEmbed.media} />
+        <PostEmbeds embed={imagesEmbed.media} moderation={{}} />
       )}
     </Link>
   )
@@ -76,4 +140,17 @@ const styles = StyleSheet.create({
     paddingLeft: 13,
     paddingRight: 8,
   },
+  errorContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 4,
+    borderRadius: 8,
+    marginTop: 8,
+    paddingVertical: 14,
+    paddingHorizontal: 14,
+    borderWidth: 1,
+  },
+  alert: {
+    marginBottom: 6,
+  },
 })
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7ffebff54..5d0090434 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -4,17 +4,18 @@ import {
   StyleProp,
   View,
   ViewStyle,
-  Image as RNImage,
   Text,
+  InteractionManager,
 } from 'react-native'
+import {Image} from 'expo-image'
 import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
-  AppBskyFeedPost,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
+  ModerationUI,
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -24,11 +25,12 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {YoutubeEmbed} from './YoutubeEmbed'
 import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
-import QuoteEmbed from './QuoteEmbed'
+import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {CustomFeedEmbed} from './CustomFeedEmbed'
 import {ListEmbed} from './ListEmbed'
 import {isDesktopWeb} from 'platform/detection'
+import {isCauseALabelOnUri} from 'lib/moderation'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -39,9 +41,11 @@ type Embed =
 
 export function PostEmbeds({
   embed,
+  moderation,
   style,
 }: {
   embed?: Embed
+  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -49,51 +53,37 @@ export function PostEmbeds({
 
   // quote post with media
   // =
-  if (
-    AppBskyEmbedRecordWithMedia.isView(embed) &&
-    AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
-    AppBskyFeedPost.isRecord(embed.record.record.value) &&
-    AppBskyFeedPost.validateRecord(embed.record.record.value).success
-  ) {
+  if (AppBskyEmbedRecordWithMedia.isView(embed)) {
+    const isModOnQuote =
+      AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
+      isCauseALabelOnUri(moderation.cause, embed.record.record.uri)
+    const mediaModeration = isModOnQuote ? {} : moderation
+    const quoteModeration = isModOnQuote ? moderation : {}
     return (
       <View style={[styles.stackContainer, style]}>
-        <PostEmbeds embed={embed.media} />
-        <QuoteEmbed
-          quote={{
-            author: embed.record.record.author,
-            cid: embed.record.record.cid,
-            uri: embed.record.record.uri,
-            indexedAt: embed.record.record.indexedAt,
-            text: embed.record.record.value.text,
-            embeds: embed.record.record.embeds,
-          }}
-        />
+        <PostEmbeds embed={embed.media} moderation={mediaModeration} />
+        <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
       </View>
     )
   }
 
-  // quote post
-  // =
   if (AppBskyEmbedRecord.isView(embed)) {
-    if (
-      AppBskyEmbedRecord.isViewRecord(embed.record) &&
-      AppBskyFeedPost.isRecord(embed.record.value) &&
-      AppBskyFeedPost.validateRecord(embed.record.value).success
-    ) {
-      return (
-        <QuoteEmbed
-          quote={{
-            author: embed.record.author,
-            cid: embed.record.cid,
-            uri: embed.record.uri,
-            indexedAt: embed.record.indexedAt,
-            text: embed.record.value.text,
-            embeds: embed.record.embeds,
-          }}
-          style={style}
-        />
-      )
+    // custom feed embed (i.e. generator view)
+    // =
+    if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
+      return <CustomFeedEmbed record={embed.record} />
     }
+
+    // list embed (e.g. mute lists; i.e. ListView)
+    if (AppBskyGraphDefs.isListView(embed.record)) {
+      return <ListEmbed item={embed.record} />
+    }
+
+    // quote post
+    // =
+    return (
+      <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} />
+    )
   }
 
   // image embed
@@ -106,14 +96,9 @@ export function PostEmbeds({
       const openLightbox = (index: number) => {
         store.shell.openLightbox(new ImagesLightbox(items, index))
       }
-      const onPressIn = (index: number) => {
-        const firstImageToShow = items[index].uri
-        RNImage.prefetch(firstImageToShow)
-        items.forEach(item => {
-          if (firstImageToShow !== item.uri) {
-            // First image already prefetched above
-            RNImage.prefetch(item.uri)
-          }
+      const onPressIn = (_: number) => {
+        InteractionManager.runAfterInteractions(() => {
+          Image.prefetch(items.map(i => i.uri))
         })
       }
 
@@ -152,23 +137,6 @@ export function PostEmbeds({
     }
   }
 
-  // custom feed embed (i.e. generator view)
-  // =
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyFeedDefs.isGeneratorView(embed.record)
-  ) {
-    return <CustomFeedEmbed record={embed.record} />
-  }
-
-  // list embed (e.g. mute lists; i.e. ListView)
-  if (
-    AppBskyEmbedRecord.isView(embed) &&
-    AppBskyGraphDefs.isListView(embed.record)
-  ) {
-    return <ListEmbed item={embed.record} />
-  }
-
   // external link embed
   // =
   if (AppBskyEmbedExternal.isView(embed)) {
diff --git a/src/view/index.ts b/src/view/index.ts
index 4226e07e7..4294508de 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -72,6 +72,8 @@ import {faShareFromSquare} from '@fortawesome/free-solid-svg-icons/faShareFromSq
 import {faShield} from '@fortawesome/free-solid-svg-icons/faShield'
 import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
 import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
+import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare'
+import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
 import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
 import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
 import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
@@ -87,6 +89,7 @@ import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
 import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
 import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
+import {faList} from '@fortawesome/free-solid-svg-icons/faList'
 
 export function setup() {
   library.add(
@@ -162,6 +165,8 @@ export function setup() {
     faShield,
     faSignal,
     faSliders,
+    faSquare,
+    faSquareCheck,
     faSquarePlus,
     faUser,
     faUsers,
@@ -177,5 +182,6 @@ export function setup() {
     faXmark,
     faPlay,
     faPause,
+    faList,
   )
 }
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index d5ecff042..2da2e2159 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -1,13 +1,14 @@
 import React, {useMemo, useRef} from 'react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
 import {usePalette} from 'lib/hooks/usePalette'
 import {HeartIcon, HeartIconSolid} from 'lib/icons'
 import {CommonNavigatorParams} from 'lib/routes/types'
 import {makeRecordUri} from 'lib/strings/url-helpers'
 import {colors, s} from 'lib/styles'
 import {observer} from 'mobx-react-lite'
-import {FlatList, StyleSheet, View} from 'react-native'
+import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
 import {useStores} from 'state/index'
 import {PostsFeedModel} from 'state/models/feeds/posts'
 import {useCustomFeed} from 'lib/hooks/useCustomFeed'
@@ -34,17 +35,98 @@ import {EmptyState} from 'view/com/util/EmptyState'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
 import {makeProfileLink} from 'lib/routes/links'
+import {resolveName} from 'lib/api'
+import {CenteredView} from 'view/com/util/Views'
+import {NavigationProp} from 'lib/routes/types'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
+
 export const CustomFeedScreen = withAuthRequired(
-  observer(({route}: Props) => {
+  observer((props: Props) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const navigation = useNavigation<NavigationProp>()
+
+    const {name: handleOrDid} = props.route.params
+
+    const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>()
+    const [error, setError] = React.useState<string | undefined>()
+
+    const onPressBack = React.useCallback(() => {
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    }, [navigation])
+
+    React.useEffect(() => {
+      /*
+       * We must resolve the DID of the feed owner before we can fetch the feed.
+       */
+      async function fetchDid() {
+        try {
+          const did = await resolveName(store, handleOrDid)
+          setFeedOwnerDid(did)
+        } catch (e) {
+          setError(
+            `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`,
+          )
+        }
+      }
+
+      fetchDid()
+    }, [store, handleOrDid, setFeedOwnerDid])
+
+    if (error) {
+      return (
+        <CenteredView>
+          <View style={[pal.view, pal.border, styles.notFoundContainer]}>
+            <Text type="title-lg" style={[pal.text, s.mb10]}>
+              Could not load feed
+            </Text>
+            <Text type="md" style={[pal.text, s.mb20]}>
+              {error}
+            </Text>
+
+            <View style={{flexDirection: 'row'}}>
+              <Button
+                type="default"
+                accessibilityLabel="Go Back"
+                accessibilityHint="Return to previous page"
+                onPress={onPressBack}
+                style={{flexShrink: 1}}>
+                <Text type="button" style={pal.text}>
+                  Go Back
+                </Text>
+              </Button>
+            </View>
+          </View>
+        </CenteredView>
+      )
+    }
+
+    return feedOwnerDid ? (
+      <CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} />
+    ) : (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }),
+)
+
+export const CustomFeedScreenInner = observer(
+  ({route, feedOwnerDid}: Props & {feedOwnerDid: string}) => {
     const store = useStores()
     const pal = usePalette('default')
     const {track} = useAnalytics()
-    const {rkey, name} = route.params
+    const {rkey, name: handleOrDid} = route.params
     const uri = useMemo(
-      () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
-      [rkey, name],
+      () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey),
+      [rkey, feedOwnerDid],
     )
     const scrollElRef = useRef<FlatList>(null)
     const currentFeed = useCustomFeed(uri)
@@ -101,10 +183,19 @@ export const CustomFeedScreen = withAuthRequired(
     }, [store, currentFeed])
 
     const onPressShare = React.useCallback(() => {
-      const url = toShareUrl(`/profile/${name}/feed/${rkey}`)
+      const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`)
       shareUrl(url)
       track('CustomFeed:Share')
-    }, [name, rkey, track])
+    }, [handleOrDid, rkey, track])
+
+    const onPressReport = React.useCallback(() => {
+      if (!currentFeed) return
+      store.shell.openModal({
+        name: 'report',
+        uri: currentFeed.uri,
+        cid: currentFeed.data.cid,
+      })
+    }, [store, currentFeed])
 
     const onScrollToTop = React.useCallback(() => {
       scrollElRef.current?.scrollToOffset({offset: 0, animated: true})
@@ -118,15 +209,37 @@ export const CustomFeedScreen = withAuthRequired(
     const dropdownItems: DropdownItem[] = React.useMemo(() => {
       let items: DropdownItem[] = [
         {
-          testID: 'feedHeaderDropdownRemoveBtn',
-          label: 'Remove from my feeds',
+          testID: 'feedHeaderDropdownToggleSavedBtn',
+          label: currentFeed?.isSaved
+            ? 'Remove from my feeds'
+            : 'Add to my feeds',
           onPress: onToggleSaved,
+          icon: currentFeed?.isSaved
+            ? {
+                ios: {
+                  name: 'trash',
+                },
+                android: 'ic_delete',
+                web: 'trash',
+              }
+            : {
+                ios: {
+                  name: 'plus',
+                },
+                android: '',
+                web: 'plus',
+              },
+        },
+        {
+          testID: 'feedHeaderDropdownReportBtn',
+          label: 'Report feed',
+          onPress: onPressReport,
           icon: {
             ios: {
-              name: 'trash',
+              name: 'exclamationmark.triangle',
             },
-            android: 'ic_delete',
-            web: 'trash',
+            android: 'ic_menu_report_image',
+            web: 'circle-exclamation',
           },
         },
         {
@@ -143,7 +256,7 @@ export const CustomFeedScreen = withAuthRequired(
         },
       ]
       return items
-    }, [onToggleSaved, onPressShare])
+    }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare])
 
     const renderHeaderBtns = React.useCallback(() => {
       return (
@@ -176,12 +289,7 @@ export const CustomFeedScreen = withAuthRequired(
               />
             </Button>
           ) : undefined}
-          {currentFeed?.isSaved ? (
-            <NativeDropdown
-              testID="feedHeaderDropdownBtn"
-              items={dropdownItems}
-            />
-          ) : (
+          {!currentFeed?.isSaved ? (
             <Button
               type="default-light"
               onPress={onToggleSaved}
@@ -193,7 +301,21 @@ export const CustomFeedScreen = withAuthRequired(
                 Add to My Feeds
               </Text>
             </Button>
-          )}
+          ) : null}
+          <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}>
+            <View
+              style={{
+                paddingLeft: currentFeed?.isSaved ? 12 : 6,
+                paddingRight: 12,
+                paddingVertical: 8,
+              }}>
+              <FontAwesomeIcon
+                icon="ellipsis"
+                size={20}
+                color={pal.colors.textLight}
+              />
+            </View>
+          </NativeDropdown>
         </View>
       )
     }, [
@@ -288,6 +410,17 @@ export const CustomFeedScreen = withAuthRequired(
                       color={pal.colors.icon}
                     />
                   </Button>
+                  <Button
+                    type="default"
+                    accessibilityLabel="Report this feed"
+                    accessibilityHint=""
+                    onPress={onPressReport}>
+                    <FontAwesomeIcon
+                      icon="circle-exclamation"
+                      size={18}
+                      color={pal.colors.icon}
+                    />
+                  </Button>
                 </View>
               )}
             </View>
@@ -310,7 +443,7 @@ export const CustomFeedScreen = withAuthRequired(
                 <TextLink
                   type="md-medium"
                   style={pal.textLight}
-                  href={`/profile/${name}/feed/${rkey}/liked-by`}
+                  href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`}
                   text={`Liked by ${currentFeed.data.likeCount} ${pluralize(
                     currentFeed?.data.likeCount || 0,
                     'user',
@@ -336,7 +469,8 @@ export const CustomFeedScreen = withAuthRequired(
       onToggleSaved,
       onToggleLiked,
       onPressShare,
-      name,
+      handleOrDid,
+      onPressReport,
       rkey,
       isPinned,
       onTogglePinned,
@@ -375,7 +509,7 @@ export const CustomFeedScreen = withAuthRequired(
         />
       </View>
     )
-  }),
+  },
 )
 
 const styles = StyleSheet.create({
@@ -430,4 +564,10 @@ const styles = StyleSheet.create({
     position: 'relative',
     top: 2,
   },
+  notFoundContainer: {
+    margin: 10,
+    paddingHorizontal: 18,
+    paddingVertical: 14,
+    borderRadius: 6,
+  },
 })
diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx
index e7b685ebc..0f15b8054 100644
--- a/src/view/screens/DiscoverFeeds.tsx
+++ b/src/view/screens/DiscoverFeeds.tsx
@@ -28,14 +28,14 @@ export const DiscoverFeedsScreen = withAuthRequired(
     const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false)
     const [query, setQuery] = React.useState<string>('')
     const debouncedSearchFeeds = React.useMemo(
-      () => debounce(() => feeds.search(query), 200), // debouce for 200 ms
-      [feeds, query],
+      () => debounce(query => feeds.search(query), 500), // debounce for 500ms
+      [feeds],
     )
     const onChangeQuery = React.useCallback(
       (text: string) => {
         setQuery(text)
         if (text.length > 1) {
-          debouncedSearchFeeds()
+          debouncedSearchFeeds(text)
         } else {
           feeds.refresh()
         }
@@ -52,8 +52,9 @@ export const DiscoverFeedsScreen = withAuthRequired(
       feeds.refresh()
     }, [feeds])
     const onSubmitQuery = React.useCallback(() => {
-      feeds.search(query)
-    }, [feeds, query])
+      debouncedSearchFeeds(query)
+      debouncedSearchFeeds.flush()
+    }, [debouncedSearchFeeds, query])
 
     useFocusEffect(
       React.useCallback(() => {
diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index 794195e58..959c6d9ca 100644
--- a/src/view/screens/ModerationBlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -66,7 +66,6 @@ export const ModerationBlockedAccounts = withAuthRequired(
         testID={`blockedAccount-${index}`}
         key={item.did}
         profile={item}
-        overrideModeration
       />
     )
     return (
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 995223c15..c638a55d7 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -63,7 +63,6 @@ export const ModerationMutedAccounts = withAuthRequired(
         testID={`mutedAccount-${index}`}
         key={item.did}
         profile={item}
-        overrideModeration
       />
     )
     return (
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index f00585336..a51fbcf50 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -232,7 +232,7 @@ export const ProfileScreen = withAuthRequired(
             )
           } else if (item instanceof PostsFeedSliceModel) {
             return (
-              <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
+              <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} />
             )
           }
         }
@@ -252,7 +252,7 @@ export const ProfileScreen = withAuthRequired(
         testID="profileView"
         style={styles.container}
         screenDescription="profile"
-        moderation={uiState.profile.moderation.view}>
+        moderation={uiState.profile.moderation.account}>
         {uiState.profile.hasError ? (
           <ErrorScreen
             testID="profileErrorScreen"
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 0502e8dc8..651fac21f 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -86,6 +86,15 @@ export const ProfileListScreen = withAuthRequired(
       })
     }, [store, list, navigation])
 
+    const onPressReportList = React.useCallback(() => {
+      if (!list.list) return
+      store.shell.openModal({
+        name: 'report',
+        uri: list.uri,
+        cid: list.list.cid,
+      })
+    }, [store, list])
+
     const onPressShareList = React.useCallback(() => {
       const url = toShareUrl(`/profile/${name}/lists/${rkey}`)
       shareUrl(url)
@@ -104,6 +113,7 @@ export const ProfileListScreen = withAuthRequired(
           onPressEditList={onPressEditList}
           onToggleSubscribed={onToggleSubscribed}
           onPressShareList={onPressShareList}
+          onPressReportList={onPressReportList}
           reversed={true}
         />
       )
@@ -114,6 +124,7 @@ export const ProfileListScreen = withAuthRequired(
       onPressEditList,
       onPressShareList,
       onToggleSubscribed,
+      onPressReportList,
     ])
 
     return (
@@ -132,6 +143,7 @@ export const ProfileListScreen = withAuthRequired(
           onToggleSubscribed={onToggleSubscribed}
           onPressEditList={onPressEditList}
           onPressDeleteList={onPressDeleteList}
+          onPressReportList={onPressReportList}
           onPressShareList={onPressShareList}
           style={[s.flex1]}
         />