From 577091d44d15680d0c14f7d4571d630bf558a74d Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Fri, 15 Mar 2024 15:45:58 +0000
Subject: ALF the birthday modal and remove legacy one

---
 src/state/modals/index.tsx | 5 -----
 1 file changed, 5 deletions(-)

(limited to 'src/state/modals/index.tsx')

diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index a18f6c87c..db5be0b8d 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -135,10 +135,6 @@ export interface PostLanguagesSettingsModal {
   name: 'post-languages-settings'
 }
 
-export interface BirthDateSettingsModal {
-  name: 'birth-date-settings'
-}
-
 export interface VerifyEmailModal {
   name: 'verify-email'
   showReminder?: boolean
@@ -179,7 +175,6 @@ export type Modal =
   | ChangeHandleModal
   | DeleteAccountModal
   | EditProfileModal
-  | BirthDateSettingsModal
   | VerifyEmailModal
   | ChangeEmailModal
   | ChangePasswordModal
-- 
cgit 1.4.1


From 20d463ff2f5a112473f75a21595b3d89b8dfc0b0 Mon Sep 17 00:00:00 2001
From: Paul Frazee <pfrazee@gmail.com>
Date: Mon, 18 Mar 2024 12:46:28 -0700
Subject: 3p moderation services [WIP] (#2550)

* Add modservice screen and profile-header-card

* Drop the guidelines for now

* Remove ununsed constants

* Add label & label group descriptions

* Not found state

* Reorg, add icon

* Subheader

* Header

* Complete header

* Clean up

* Add all groups

* Fix scroll view

* Dialogs side quest

* Remove log

* Add (WIP) debug mod page

* Dialog solution

* Add note

* Clean up and reorganize localized moderation strings

* Memoize

* Add example

* Add first ReportDialog screen

* Report dialog step 2

* Submit

* Integrate updates

* Move moderation screen

* Migrate buttons

* Migrate everything

* Rough sketch

* Fix types

* Update atoms values

* Abstract ModerationServiceCard

* Hook up data to settings page

* Handle subscription

* Rough enablement

* Rough enablement

* Some validation, fixes

* More work on the mod debug screen

* Hook up data

* Update invalidation

* Hook up data to ReportDialog

* Fix native error

* Refactor/rewrite the entire moderation-application system

* Fix toggles

* Add copyright and other option to report

* Handle reports on profile vs content

* Little cleanup

* Get post hiding back in gear

* Better loading flow on Mod screen

* Clean up Mod screen

* Clean up ProfileMod screen

* Handle muting correctly

* Update enablement on ProfileMod screen

* Improve Moderation screen and dialog

* Styling, handle disabled labelers

* Rework list of labels on own content

* Use moderateNotification()

* ReportDialog updates

* Fix button overflow

* Simplify the ProfileModerationService ui

* Mod screen design

* Move moderation card from the profile header to a tab

* Small tweaks to the moderation screen

* Enable toggle on mod page

* Add notifs to debugmod and dont filter notifs from followed users

* Add moderator-service profile view

* Wire up more of the modservice data to profiles

* A bunch of speculative non-working UI

* Cleanup: delete old code

* Update ModerationDetailsDialog

* Update ReportDialog

* Update LabelsOnMe dialog

* Handle ReportDialog load better

* Rename LabelsOnMeDialog, fix close

* Experiment to put labeling under a tab of a normal profile

* Moderator variation of profile

* Remove dead code and start moving toward latest modsdk

* Remove a bunch of now-dead label strings

* Update ModDebug to be a bit more intuitive and support custom labels

* Minor ui tweaks

* Improve consistency of display name blurring

* Fix profile-card warning rendering

* More debugmod UI tuning

* Update to use new labeler semantics

* Delete some dead code and do some refactoring

* Update profile to pull from labeler definition

* Implement new label config controls (wip)

* Tweak ui

* Implement preference controls on labelers

* Rework label pref ui

* Get moderation screen working

* Add asyncstorage query persistence

* Implement label handling

* Small cleanup

* Implement Likes dialog

* Fix: remove text outside of text element

* Cleanup

* Fix likes dialog on mobile

* Implement the label appeal flow

* Get report flow working again with temporarily fixed report options

* Update onboarding

* Enforce limit of ten labeler subscriptions

* Fix type errors

* Fix lint errors

* Improve types of RQ

* Some work on Likes dialog, needs discussion

* Bit of ReportDialog cleanup

* Replace non-single-path SVG

* Update nudity descriptions

* Update to use new sdk updates

* Add adult-content-enabled behavior to label config

* Use the default setting of custom labels

* Handle global moderation label prefs with the global settings

* Fix missing postAuthor

* Fix empty moderation page

* Add mutewords control back to Mod screen

* Tweak adult setting styles

* Remove deprecated global labels

* Handle underage users on mod screen

* Adjust font sizes

* Swap in RichText

* Like button improvements

* Tweaks to Labeler profile

* Design tweaks for mod pref dialog

* Add tertiary button color

* Switch moderation UIs to tertiary color

* Update mutewords and hiddenposts to use the new sdk

* Add test-environment mod authority

* Switch 'gore' to 'graphic-media'

* Move nudity out of the adult content control

* Remove focus styles from buttons - let the browser behavior handle it

* Fixes to the adult content age-gating in moderaiton

* Ditch tertiary button color, lighten secondary button

* Fix some colors

* Remove focused overrides from toggles

* Liked by screen

* Rework the moderationlabelpref

* Fix optimistic like

* Cleanup

* Change how onboarding handles adult content enabled/disabled

* Add special handling of the mod authorities

* Tweaks

* Update the default labeler avatar to a shield

* Add route to go server

* Avoid dups due to bad config

* Fix attrs

* Fix: dont try to detect link/label mismatches on post meta

* Correctly show the label behavior when adult content is disabled

* Readd the local hiddenPosts handling

* WIP

* Fix bad merge

* Conten hider design tweaks

* Fix text string breakage

* Adjust source text in ContentHider

* Fix link bug

* Design tweaks to ContentHider and ModDetailsDialog

* Adjust spacing of inform badges

* Adjust spacing of embeds in posts

* Style tweaks to post/profile alerts

* Labels on me and dialog

* Remove bad focus styles from post dropdown

* Better spacing solution

* Tune moderation UIs

* Moderation UI tweaks for mobile

* Move labelers query on Mod screen

* Update to use new SDK appLabelers semantics

* Implement report submission

* Replace the report modal entirely with the report dialog

* Add @ to mod details dialog handle

* Bump SDK package

* Remove silly type

* Add to AWS build CI

* Fix ToggleButton overflow

* Clean up ModServiceCard, rename to LabelingServiceCard

* Hackfix to translate gore labels to graphic-media

* Tune content hider sizing on web desktop

* Handle self labels

* Fix spacing below text-only posts

* Fix: send appeals to the right labeler

* Give mod page links interactive states

* Fix references

* Remove focus handling

* Remove remnant

* Remove the like count from the subscribed labeler listing

* Bump @atproto/api@0.11.1

* Remove extra @

* Fix: persist labels to local storage to reduce coverage gaps

* update dipendencies

* revert dipendencies

* Add some explainers on how blocking affects labelers

* Tweak copy

* Fix underline color in header

* Fix profile menu

* Handle card overflow

* Remove metrics from header

* Mute 'account' not 'user'

* Show metrics if self

* Show the labels tab on logged out view

* Fix bad merge

* Use purple theming on labelers

* Tighten space on LabelerCard

* Set staleTime to 6hrs for labeler details

* Memoize the memoizers

* Drop staleTime to 60s

* Move label defs into a context to reduce recomputes

* Submit view tweaks

* Move labeler fetch below auth

* Mitigation: hardcode the bluesky moderation labeler name

* Bump sdk

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Add missing translated string

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Hailey's fix for incorrect profile tabs

Co-authored-by: Hailey <me@haileyok.com>

* Feedback

* Fix borders, add bottom space

* Hailey's fix pt 2

Co-authored-by: Hailey <me@haileyok.com>

* Fix post tabs

* Integrate feedback pt 1

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 2

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 3

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 4

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 5

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 6

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 7

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Integrate feedback pt 8

Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>

* Format

* Integrate new bday modal

* Use public agent for getServices

* Update casing

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Takayuki KUSANO <65759+tkusano@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
---
 .github/workflows/build-and-push-bskyweb-aws.yaml  |   4 +-
 ...arrowTriangleBottom_stroke2_corner1_rounded.svg |   1 +
 assets/icons/bars3_stroke2_corner0_rounded.svg     |   1 +
 .../chevronBottom_stroke2_corner0_rounded.svg      |   1 +
 .../icons/chevronTop_stroke2_corner0_rounded.svg   |   1 +
 .../circleBanSign_stroke2_corner0_rounded.svg      |   1 +
 assets/icons/group3_stroke2_corner0_rounded.svg    |   1 +
 .../heart2_filled_stroke2_corner0_rounded.svg      |   1 +
 assets/icons/heart2_stroke2_corner0_rounded.svg    |   1 +
 assets/icons/person_stroke2_corner0_rounded.svg    |   1 +
 .../raisingHand4Finger_stroke2_corner0_rounded.svg |   1 +
 .../settingsGear2_stroke2_corner0_rounded.svg      |   1 +
 assets/icons/shield_stroke2_corner0_rounded.svg    |   1 +
 ...squareArrowTopRight_stroke2_corner0_rounded.svg |   1 +
 ...squareBehindSquare4_stroke2_corner0_rounded.svg |   1 +
 ...triangleExclamation_stroke2_corner2_rounded.svg |   1 +
 bskyweb/cmd/bskyweb/server.go                      |   2 +
 package.json                                       |   4 +-
 src/App.native.tsx                                 |  47 +-
 src/App.web.tsx                                    |  47 +-
 src/Navigation.tsx                                 |  14 +-
 src/alf/atoms.ts                                   |  10 +
 src/alf/tokens.ts                                  |   3 +
 src/components/Button.tsx                          |  28 +-
 src/components/Dialog/index.web.tsx                |   2 +-
 src/components/GradientFill.tsx                    |  27 +
 src/components/LabelingServiceCard/index.tsx       | 182 ++++
 src/components/LikedByList.tsx                     | 109 +++
 src/components/LikesDialog.tsx                     | 131 +++
 src/components/Link.tsx                            |   2 +-
 src/components/Lists.tsx                           |   2 +-
 src/components/Menu/index.web.tsx                  |   2 +-
 .../ReportDialog/SelectReportOptionView.tsx        | 183 ++++
 src/components/ReportDialog/SubmitView.tsx         | 262 ++++++
 src/components/ReportDialog/const.ts               |   1 +
 src/components/ReportDialog/index.tsx              |  73 ++
 src/components/ReportDialog/types.ts               |  15 +
 src/components/TagMenu/index.tsx                   |   2 +-
 src/components/TagMenu/index.web.tsx               |   2 +-
 src/components/Typography.tsx                      |   9 +-
 src/components/dialogs/Context.tsx                 |   2 +-
 src/components/dialogs/MutedWords.tsx              |   4 +-
 src/components/forms/Toggle.tsx                    |  40 +-
 src/components/forms/ToggleButton.tsx              |   8 +-
 src/components/hooks/useDelayedLoading.ts          |  15 +
 src/components/icons/ArrowTriangle.tsx             |   5 +
 src/components/icons/Bars.tsx                      |   5 +
 src/components/icons/Chevron.tsx                   |   8 +
 src/components/icons/CircleBanSign.tsx             |   5 +
 src/components/icons/Gear.tsx                      |   5 +
 src/components/icons/Group.tsx                     |   5 +
 src/components/icons/Group3.tsx                    |   5 -
 src/components/icons/RaisingHand.tsx               |   5 +
 src/components/icons/Shield.tsx                    |   5 +
 src/components/icons/SquareArrowTopRight.tsx       |   5 +
 src/components/icons/SquareBehindSquare4.tsx       |   5 +
 src/components/moderation/ContentHider.tsx         | 182 ++++
 .../moderation/GlobalModerationLabelPref.tsx       |  93 +++
 src/components/moderation/LabelsOnMe.tsx           |  83 ++
 src/components/moderation/LabelsOnMeDialog.tsx     | 262 ++++++
 .../moderation/ModerationDetailsDialog.tsx         | 148 ++++
 src/components/moderation/ModerationLabelPref.tsx  | 154 ++++
 src/components/moderation/PostAlerts.tsx           |  66 ++
 src/components/moderation/PostHider.tsx            | 129 +++
 src/components/moderation/ProfileHeaderAlerts.tsx  |  66 ++
 src/components/moderation/ScreenHider.tsx          | 171 ++++
 src/lib/__tests__/moderatePost_wrapped.test.ts     | 692 ---------------
 src/lib/constants.ts                               |   4 +
 src/lib/moderatePost_wrapped.ts                    | 384 +--------
 src/lib/moderation.ts                              | 196 ++---
 src/lib/moderation/useGlobalLabelStrings.ts        |  52 ++
 src/lib/moderation/useLabelBehaviorDescription.ts  |  70 ++
 src/lib/moderation/useLabelInfo.ts                 | 100 +++
 .../moderation/useModerationCauseDescription.ts    | 146 ++++
 src/lib/moderation/useReportOptions.ts             |  94 +++
 src/lib/react-query.ts                             |  20 +
 src/lib/routes/types.ts                            |   2 +
 src/lib/strings/display-names.ts                   |   3 +-
 src/lib/themes.ts                                  |   2 +-
 src/routes.ts                                      |   2 +
 src/screens/Moderation/index.tsx                   | 560 +++++++++++++
 .../StepModeration/AdultContentEnabledPref.tsx     |   9 +-
 .../Onboarding/StepModeration/ModerationOption.tsx |  91 +-
 src/screens/Onboarding/StepModeration/index.tsx    |  48 +-
 .../StepSuggestedAccounts/SuggestedAccountCard.tsx |   2 +-
 .../Onboarding/StepSuggestedAccounts/index.tsx     |   2 +-
 src/screens/Onboarding/StepTopicalFeeds.tsx        |   8 +-
 src/screens/Profile/ErrorState.tsx                 |  72 ++
 src/screens/Profile/Header/DisplayName.tsx         |  31 +
 src/screens/Profile/Header/Handle.tsx              |  46 +
 src/screens/Profile/Header/Metrics.tsx             |  61 ++
 .../Profile/Header/ProfileHeaderLabeler.tsx        | 329 ++++++++
 .../Profile/Header/ProfileHeaderStandard.tsx       | 286 +++++++
 src/screens/Profile/Header/Shell.tsx               | 164 ++++
 src/screens/Profile/Header/index.tsx               |  78 ++
 src/screens/Profile/ProfileLabelerLikedBy.tsx      |  46 +
 src/screens/Profile/Sections/Feed.tsx              |  88 ++
 src/screens/Profile/Sections/Labels.tsx            | 233 ++++++
 src/screens/Profile/Sections/types.ts              |   3 +
 src/state/modals/index.tsx                         |  38 +-
 src/state/preferences/index.tsx                    |   1 +
 src/state/preferences/label-defs.tsx               |  25 +
 src/state/queries/actor-autocomplete.ts            |  19 +-
 src/state/queries/labeler.ts                       |  89 ++
 src/state/queries/notifications/util.ts            |  30 +-
 src/state/queries/post-feed.ts                     |  26 +-
 src/state/queries/post-liked-by.ts                 |   4 +-
 src/state/queries/preferences/const.ts             |  18 +-
 src/state/queries/preferences/index.ts             | 100 +--
 src/state/queries/preferences/moderation.ts        | 218 +----
 src/state/queries/preferences/types.ts             |  33 -
 src/state/queries/preferences/util.ts              |  16 -
 src/state/queries/profile-extra-info.ts            |  34 -
 src/state/queries/suggested-follows.ts             |   3 +-
 src/state/session/agent-config.ts                  |  12 +
 src/state/session/index.tsx                        |  41 +-
 src/state/shell/composer.tsx                       |   4 +-
 src/view/com/auth/create/state.ts                  |   4 +-
 .../com/auth/onboarding/RecommendedFollowsItem.tsx |  10 +-
 src/view/com/composer/Composer.tsx                 |   2 +-
 src/view/com/composer/ComposerReplyTo.tsx          |   6 +-
 src/view/com/modals/AppealLabel.tsx                | 139 ----
 src/view/com/modals/ContentFilteringSettings.tsx   | 407 ---------
 src/view/com/modals/Modal.tsx                      |  16 -
 src/view/com/modals/Modal.web.tsx                  |  12 -
 src/view/com/modals/ModerationDetails.tsx          | 142 ----
 src/view/com/modals/report/InputIssueDetails.tsx   | 100 ---
 src/view/com/modals/report/Modal.tsx               | 223 -----
 src/view/com/modals/report/ReasonOptions.tsx       | 123 ---
 src/view/com/modals/report/SendReportButton.tsx    |  62 --
 src/view/com/modals/report/types.ts                |   8 -
 src/view/com/notifications/FeedItem.tsx            |  10 +-
 src/view/com/post-thread/PostLikedBy.tsx           |   4 +-
 src/view/com/post-thread/PostThread.tsx            |  11 +-
 src/view/com/post-thread/PostThreadItem.tsx        |  90 +-
 src/view/com/post/Post.tsx                         |  32 +-
 src/view/com/posts/FeedItem.tsx                    |  49 +-
 src/view/com/profile/ProfileCard.tsx               |  91 +-
 src/view/com/profile/ProfileHeader.tsx             | 598 -------------
 .../com/profile/ProfileHeaderSuggestedFollows.tsx  |   4 +-
 src/view/com/profile/ProfileMenu.tsx               |  83 +-
 src/view/com/util/Link.tsx                         |   8 +-
 src/view/com/util/PostMeta.tsx                     |  10 +-
 src/view/com/util/UserAvatar.tsx                   |  29 +-
 src/view/com/util/UserBanner.tsx                   |  12 +-
 src/view/com/util/forms/PostDropdownBtn.tsx        |  46 +-
 src/view/com/util/moderation/ContentHider.tsx      | 145 ----
 src/view/com/util/moderation/LabelInfo.tsx         |  61 --
 src/view/com/util/moderation/PostAlerts.tsx        |  67 --
 src/view/com/util/moderation/PostHider.tsx         | 142 ----
 .../com/util/moderation/ProfileHeaderAlerts.tsx    |  89 --
 src/view/com/util/moderation/ScreenHider.tsx       | 180 ----
 src/view/com/util/post-ctrls/PostCtrls.tsx         |   3 -
 src/view/com/util/post-embeds/QuoteEmbed.tsx       | 129 ++-
 src/view/com/util/post-embeds/index.tsx            | 106 ++-
 src/view/screens/DebugMod.tsx                      | 923 +++++++++++++++++++++
 src/view/screens/Moderation.tsx                    | 304 -------
 src/view/screens/Profile.tsx                       | 253 +++---
 src/view/screens/ProfileFeed.tsx                   |  21 +-
 src/view/screens/ProfileList.tsx                   |  18 +-
 src/view/screens/Settings/index.tsx                |  14 +
 src/view/screens/Storybook/Buttons.tsx             |  41 +
 src/view/screens/Storybook/index.tsx               |   1 +
 src/view/shell/desktop/Search.tsx                  |   8 +-
 src/view/shell/index.tsx                           |   2 +-
 src/view/shell/index.web.tsx                       |   3 +-
 yarn.lock                                          |  86 +-
 167 files changed, 7123 insertions(+), 5098 deletions(-)
 create mode 100644 assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
 create mode 100644 assets/icons/bars3_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/chevronBottom_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/chevronTop_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/circleBanSign_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/group3_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/heart2_filled_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/heart2_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/person_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/settingsGear2_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/shield_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
 create mode 100644 src/components/GradientFill.tsx
 create mode 100644 src/components/LabelingServiceCard/index.tsx
 create mode 100644 src/components/LikedByList.tsx
 create mode 100644 src/components/LikesDialog.tsx
 create mode 100644 src/components/ReportDialog/SelectReportOptionView.tsx
 create mode 100644 src/components/ReportDialog/SubmitView.tsx
 create mode 100644 src/components/ReportDialog/const.ts
 create mode 100644 src/components/ReportDialog/index.tsx
 create mode 100644 src/components/ReportDialog/types.ts
 create mode 100644 src/components/hooks/useDelayedLoading.ts
 create mode 100644 src/components/icons/ArrowTriangle.tsx
 create mode 100644 src/components/icons/Bars.tsx
 create mode 100644 src/components/icons/CircleBanSign.tsx
 create mode 100644 src/components/icons/Gear.tsx
 create mode 100644 src/components/icons/Group.tsx
 delete mode 100644 src/components/icons/Group3.tsx
 create mode 100644 src/components/icons/RaisingHand.tsx
 create mode 100644 src/components/icons/Shield.tsx
 create mode 100644 src/components/icons/SquareArrowTopRight.tsx
 create mode 100644 src/components/icons/SquareBehindSquare4.tsx
 create mode 100644 src/components/moderation/ContentHider.tsx
 create mode 100644 src/components/moderation/GlobalModerationLabelPref.tsx
 create mode 100644 src/components/moderation/LabelsOnMe.tsx
 create mode 100644 src/components/moderation/LabelsOnMeDialog.tsx
 create mode 100644 src/components/moderation/ModerationDetailsDialog.tsx
 create mode 100644 src/components/moderation/ModerationLabelPref.tsx
 create mode 100644 src/components/moderation/PostAlerts.tsx
 create mode 100644 src/components/moderation/PostHider.tsx
 create mode 100644 src/components/moderation/ProfileHeaderAlerts.tsx
 create mode 100644 src/components/moderation/ScreenHider.tsx
 delete mode 100644 src/lib/__tests__/moderatePost_wrapped.test.ts
 create mode 100644 src/lib/moderation/useGlobalLabelStrings.ts
 create mode 100644 src/lib/moderation/useLabelBehaviorDescription.ts
 create mode 100644 src/lib/moderation/useLabelInfo.ts
 create mode 100644 src/lib/moderation/useModerationCauseDescription.ts
 create mode 100644 src/lib/moderation/useReportOptions.ts
 create mode 100644 src/screens/Moderation/index.tsx
 create mode 100644 src/screens/Profile/ErrorState.tsx
 create mode 100644 src/screens/Profile/Header/DisplayName.tsx
 create mode 100644 src/screens/Profile/Header/Handle.tsx
 create mode 100644 src/screens/Profile/Header/Metrics.tsx
 create mode 100644 src/screens/Profile/Header/ProfileHeaderLabeler.tsx
 create mode 100644 src/screens/Profile/Header/ProfileHeaderStandard.tsx
 create mode 100644 src/screens/Profile/Header/Shell.tsx
 create mode 100644 src/screens/Profile/Header/index.tsx
 create mode 100644 src/screens/Profile/ProfileLabelerLikedBy.tsx
 create mode 100644 src/screens/Profile/Sections/Feed.tsx
 create mode 100644 src/screens/Profile/Sections/Labels.tsx
 create mode 100644 src/screens/Profile/Sections/types.ts
 create mode 100644 src/state/preferences/label-defs.tsx
 create mode 100644 src/state/queries/labeler.ts
 delete mode 100644 src/state/queries/preferences/util.ts
 delete mode 100644 src/state/queries/profile-extra-info.ts
 create mode 100644 src/state/session/agent-config.ts
 delete mode 100644 src/view/com/modals/AppealLabel.tsx
 delete mode 100644 src/view/com/modals/ContentFilteringSettings.tsx
 delete mode 100644 src/view/com/modals/ModerationDetails.tsx
 delete mode 100644 src/view/com/modals/report/InputIssueDetails.tsx
 delete mode 100644 src/view/com/modals/report/Modal.tsx
 delete mode 100644 src/view/com/modals/report/ReasonOptions.tsx
 delete mode 100644 src/view/com/modals/report/SendReportButton.tsx
 delete mode 100644 src/view/com/modals/report/types.ts
 delete mode 100644 src/view/com/profile/ProfileHeader.tsx
 delete mode 100644 src/view/com/util/moderation/ContentHider.tsx
 delete mode 100644 src/view/com/util/moderation/LabelInfo.tsx
 delete mode 100644 src/view/com/util/moderation/PostAlerts.tsx
 delete mode 100644 src/view/com/util/moderation/PostHider.tsx
 delete mode 100644 src/view/com/util/moderation/ProfileHeaderAlerts.tsx
 delete mode 100644 src/view/com/util/moderation/ScreenHider.tsx
 create mode 100644 src/view/screens/DebugMod.tsx
 delete mode 100644 src/view/screens/Moderation.tsx

(limited to 'src/state/modals/index.tsx')

diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml
index fef24f952..3f6070579 100644
--- a/.github/workflows/build-and-push-bskyweb-aws.yaml
+++ b/.github/workflows/build-and-push-bskyweb-aws.yaml
@@ -3,8 +3,8 @@ on:
   push:
     branches:
       - main
-      - traffic-reduction
-      - respect-optout-for-embeds
+      - 3p-moderators
+
 env:
   REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
   USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
diff --git a/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
new file mode 100644
index 000000000..f40546f7c
--- /dev/null
+++ b/assets/icons/arrowTriangleBottom_stroke2_corner1_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z"/></svg>
\ No newline at end of file
diff --git a/assets/icons/bars3_stroke2_corner0_rounded.svg b/assets/icons/bars3_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..cbcb531a6
--- /dev/null
+++ b/assets/icons/bars3_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/chevronBottom_stroke2_corner0_rounded.svg b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..705c1c513
--- /dev/null
+++ b/assets/icons/chevronBottom_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/chevronTop_stroke2_corner0_rounded.svg b/assets/icons/chevronTop_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..da94ba911
--- /dev/null
+++ b/assets/icons/chevronTop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/circleBanSign_stroke2_corner0_rounded.svg b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..73251477f
--- /dev/null
+++ b/assets/icons/circleBanSign_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/group3_stroke2_corner0_rounded.svg b/assets/icons/group3_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..2a8f43a8a
--- /dev/null
+++ b/assets/icons/group3_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/heart2_filled_stroke2_corner0_rounded.svg b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..1dfefb4c9
--- /dev/null
+++ b/assets/icons/heart2_filled_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"/></svg>
diff --git a/assets/icons/heart2_stroke2_corner0_rounded.svg b/assets/icons/heart2_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..5b3da8e00
--- /dev/null
+++ b/assets/icons/heart2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/person_stroke2_corner0_rounded.svg b/assets/icons/person_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..a23ad7607
--- /dev/null
+++ b/assets/icons/person_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..aed3d9e7e
--- /dev/null
+++ b/assets/icons/raisingHand4Finger_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/settingsGear2_stroke2_corner0_rounded.svg b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..de8a57919
--- /dev/null
+++ b/assets/icons/settingsGear2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/shield_stroke2_corner0_rounded.svg b/assets/icons/shield_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..c4ef98e5a
--- /dev/null
+++ b/assets/icons/shield_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..1407a1d6f
--- /dev/null
+++ b/assets/icons/squareArrowTopRight_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..3fa7e5d39
--- /dev/null
+++ b/assets/icons/squareBehindSquare4_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
new file mode 100644
index 000000000..aa5640445
--- /dev/null
+++ b/assets/icons/triangleExclamation_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.86 4.494a.995.995 0 0 0-1.72 0L4.14 16.502A.996.996 0 0 0 4.999 18h14.003a.996.996 0 0 0 .86-1.498L12.86 4.494ZM9.413 3.487c1.155-1.983 4.019-1.983 5.174 0l7.002 12.007C22.753 17.491 21.314 20 19.002 20H4.998c-2.312 0-3.751-2.509-2.587-4.506L9.413 3.487ZM12 8.019a1 1 0 0 1 1 1v2.994a1 1 0 1 1-2 0V9.02a1 1 0 0 1 1-1Z" clip-rule="evenodd"/><rect width="2.5" height="2.5" x="10.75" y="13.75" fill="#000" rx="1.25"/></svg>
\ No newline at end of file
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index e159d780a..54a3925c6 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -188,6 +188,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/settings/threads", server.WebGeneric)
 	e.GET("/settings/external-embeds", server.WebGeneric)
 	e.GET("/sys/debug", server.WebGeneric)
+	e.GET("/sys/debug-mod", server.WebGeneric)
 	e.GET("/sys/log", server.WebGeneric)
 	e.GET("/support", server.WebGeneric)
 	e.GET("/support/privacy", server.WebGeneric)
@@ -203,6 +204,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric)
 	e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
+	e.GET("/profile/:handleOrDID/labeler/liked-by", server.WebGeneric)
 
 	// profile RSS feed (DID not handle)
 	e.GET("/profile/:ident/rss", server.WebProfileRSS)
diff --git a/package.json b/package.json
index 896201820..30df66950 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,7 @@
     "update-extensions": "scripts/updateExtensions.sh"
   },
   "dependencies": {
-    "@atproto/api": "^0.10.5",
+    "@atproto/api": "^0.11.2",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
@@ -76,7 +76,9 @@
     "@segment/sovran-react-native": "^0.4.5",
     "@sentry/react-native": "5.5.0",
     "@tamagui/focus-scope": "^1.84.1",
+    "@tanstack/query-async-storage-persister": "^5.25.0",
     "@tanstack/react-query": "^5.8.1",
+    "@tanstack/react-query-persist-client": "^5.25.0",
     "@tiptap/core": "^2.0.0-beta.220",
     "@tiptap/extension-document": "^2.0.0-beta.220",
     "@tiptap/extension-hard-break": "^2.0.3",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index eff8ab099..e825ffa00 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react'
 import {RootSiblingParent} from 'react-native-root-siblings'
 import * as SplashScreen from 'expo-splash-screen'
 import {GestureHandlerRootView} from 'react-native-gesture-handler'
-import {QueryClientProvider} from '@tanstack/react-query'
+import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
 import {
   SafeAreaProvider,
   initialWindowMetrics,
@@ -22,7 +22,11 @@ import {s} from 'lib/styles'
 import {Shell} from 'view/shell'
 import * as notifications from 'lib/notifications/notifications'
 import * as Toast from 'view/com/util/Toast'
-import {queryClient} from 'lib/react-query'
+import {
+  queryClient,
+  asyncStoragePersister,
+  dehydrateOptions,
+} from 'lib/react-query'
 import {TestCtrls} from 'view/com/testing/TestCtrls'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as ModalStateProvider} from 'state/modals'
@@ -33,6 +37,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
 import {Provider as PrefsStateProvider} from 'state/preferences'
 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
 import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
+import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
 import I18nProvider from './locale/i18nProvider'
 import {
   Provider as SessionProvider,
@@ -79,21 +84,23 @@ function InnerApp() {
             // Resets the entire tree below when it changes:
             key={currentAccount?.did}>
             <StatsigProvider>
-              <LoggedOutViewProvider>
-                <SelectedFeedProvider>
-                  <UnreadNotifsProvider>
-                    <ThemeProvider theme={theme}>
-                      {/* All components should be within this provider */}
-                      <RootSiblingParent>
-                        <GestureHandlerRootView style={s.h100pct}>
-                          <TestCtrls />
-                          <Shell />
-                        </GestureHandlerRootView>
-                      </RootSiblingParent>
-                    </ThemeProvider>
-                  </UnreadNotifsProvider>
-                </SelectedFeedProvider>
-              </LoggedOutViewProvider>
+              <LabelDefsProvider>
+                <LoggedOutViewProvider>
+                  <SelectedFeedProvider>
+                    <UnreadNotifsProvider>
+                      <ThemeProvider theme={theme}>
+                        {/* All components should be within this provider */}
+                        <RootSiblingParent>
+                          <GestureHandlerRootView style={s.h100pct}>
+                            <TestCtrls />
+                            <Shell />
+                          </GestureHandlerRootView>
+                        </RootSiblingParent>
+                      </ThemeProvider>
+                    </UnreadNotifsProvider>
+                  </SelectedFeedProvider>
+                </LoggedOutViewProvider>
+              </LabelDefsProvider>
             </StatsigProvider>
           </React.Fragment>
         </Splash>
@@ -118,7 +125,9 @@ function App() {
    * that is set up in the InnerApp component above.
    */
   return (
-    <QueryClientProvider client={queryClient}>
+    <PersistQueryClientProvider
+      client={queryClient}
+      persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
       <SessionProvider>
         <ShellStateProvider>
           <PrefsStateProvider>
@@ -140,7 +149,7 @@ function App() {
           </PrefsStateProvider>
         </ShellStateProvider>
       </SessionProvider>
-    </QueryClientProvider>
+    </PersistQueryClientProvider>
   )
 }
 
diff --git a/src/App.web.tsx b/src/App.web.tsx
index eb2e42593..f47f763da 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -1,7 +1,7 @@
 import 'lib/sentry' // must be near top
 
 import React, {useState, useEffect} from 'react'
-import {QueryClientProvider} from '@tanstack/react-query'
+import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
 import {SafeAreaProvider} from 'react-native-safe-area-context'
 import {RootSiblingParent} from 'react-native-root-siblings'
 
@@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted'
 import {Shell} from 'view/shell/index'
 import {ToastContainer} from 'view/com/util/Toast.web'
 import {ThemeProvider} from 'lib/ThemeContext'
-import {queryClient} from 'lib/react-query'
+import {
+  queryClient,
+  asyncStoragePersister,
+  dehydrateOptions,
+} from 'lib/react-query'
 import {Provider as ShellStateProvider} from 'state/shell'
 import {Provider as ModalStateProvider} from 'state/modals'
 import {Provider as DialogStateProvider} from 'state/dialogs'
@@ -23,6 +27,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
 import {Provider as PrefsStateProvider} from 'state/preferences'
 import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
 import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
+import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
 import I18nProvider from './locale/i18nProvider'
 import {
   Provider as SessionProvider,
@@ -56,21 +61,23 @@ function InnerApp() {
         // Resets the entire tree below when it changes:
         key={currentAccount?.did}>
         <StatsigProvider>
-          <LoggedOutViewProvider>
-            <SelectedFeedProvider>
-              <UnreadNotifsProvider>
-                <ThemeProvider theme={theme}>
-                  {/* All components should be within this provider */}
-                  <RootSiblingParent>
-                    <SafeAreaProvider>
-                      <Shell />
-                    </SafeAreaProvider>
-                  </RootSiblingParent>
-                  <ToastContainer />
-                </ThemeProvider>
-              </UnreadNotifsProvider>
-            </SelectedFeedProvider>
-          </LoggedOutViewProvider>
+          <LabelDefsProvider>
+            <LoggedOutViewProvider>
+              <SelectedFeedProvider>
+                <UnreadNotifsProvider>
+                  <ThemeProvider theme={theme}>
+                    {/* All components should be within this provider */}
+                    <RootSiblingParent>
+                      <SafeAreaProvider>
+                        <Shell />
+                      </SafeAreaProvider>
+                    </RootSiblingParent>
+                    <ToastContainer />
+                  </ThemeProvider>
+                </UnreadNotifsProvider>
+              </SelectedFeedProvider>
+            </LoggedOutViewProvider>
+          </LabelDefsProvider>
         </StatsigProvider>
       </React.Fragment>
     </Alf>
@@ -93,7 +100,9 @@ function App() {
    * that is set up in the InnerApp component above.
    */
   return (
-    <QueryClientProvider client={queryClient}>
+    <PersistQueryClientProvider
+      client={queryClient}
+      persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
       <SessionProvider>
         <ShellStateProvider>
           <PrefsStateProvider>
@@ -115,7 +124,7 @@ function App() {
           </PrefsStateProvider>
         </ShellStateProvider>
       </SessionProvider>
-    </QueryClientProvider>
+    </PersistQueryClientProvider>
   )
 }
 
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 77706ce34..3d6a15c4e 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search'
 import {FeedsScreen} from './view/screens/Feeds'
 import {NotificationsScreen} from './view/screens/Notifications'
 import {ListsScreen} from './view/screens/Lists'
-import {ModerationScreen} from './view/screens/Moderation'
+import {ModerationScreen} from '#/screens/Moderation'
 import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
 import {NotFoundScreen} from './view/screens/NotFound'
 import {SettingsScreen} from './view/screens/Settings'
@@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread'
 import {PostLikedByScreen} from './view/screens/PostLikedBy'
 import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
 import {Storybook} from './view/screens/Storybook'
+import {DebugModScreen} from './view/screens/DebugMod'
 import {LogScreen} from './view/screens/Log'
 import {SupportScreen} from './view/screens/Support'
 import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
 import {msg} from '@lingui/macro'
 import {i18n, MessageDescriptor} from '@lingui/core'
 import HashtagScreen from '#/screens/Hashtag'
+import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
 import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         getComponent={() => ProfileFeedLikedByScreen}
         options={{title: title(msg`Liked by`)}}
       />
+      <Stack.Screen
+        name="ProfileLabelerLikedBy"
+        getComponent={() => ProfileLabelerLikedByScreen}
+        options={{title: title(msg`Liked by`)}}
+      />
       <Stack.Screen
         name="Debug"
         getComponent={() => Storybook}
         options={{title: title(msg`Storybook`), requireAuth: true}}
       />
+      <Stack.Screen
+        name="DebugMod"
+        getComponent={() => DebugModScreen}
+        options={{title: title(msg`Moderation states`), requireAuth: true}}
+      />
       <Stack.Screen
         name="Log"
         getComponent={() => LogScreen}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 5088e3aac..0b473ba90 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -50,6 +50,9 @@ export const atoms = {
   h_full: {
     height: '100%',
   },
+  h_full_vh: web({
+    height: '100vh',
+  }),
 
   /*
    * Border radius
@@ -243,6 +246,9 @@ export const atoms = {
   font_normal: {
     fontWeight: tokens.fontWeight.normal,
   },
+  font_semibold: {
+    fontWeight: '500',
+  },
   font_bold: {
     fontWeight: tokens.fontWeight.semibold,
   },
@@ -528,6 +534,10 @@ export const atoms = {
   /*
    * Margin
    */
+  mx_auto: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
   m_2xs: {
     margin: tokens.space._2xs,
   },
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index b1468f461..4045c831c 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -12,6 +12,9 @@ export const dimScale = generateScale(12, 100)
 export const color = {
   trueBlack: '#000000',
 
+  temp_purple: 'rgb(105 0 255)',
+  temp_purple_dark: 'rgb(83 0 202)',
+
   gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
   gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
   gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index d3bf73cc3..0e22944a3 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
 
 import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
 import {Props as SVGIconProps} from '#/components/icons/common'
+import {normalizeTextStyles} from '#/components/Typography'
 
 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
 export type ButtonColor =
@@ -139,7 +140,7 @@ export function Button({
     }))
   }, [setState])
 
-  const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
+  const {baseStyles, hoverStyles} = React.useMemo(() => {
     const baseStyles: ViewStyle[] = []
     const hoverStyles: ViewStyle[] = []
     const light = t.name === 'light'
@@ -191,14 +192,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: t.palette.contrast_50,
+            backgroundColor: t.palette.contrast_25,
           })
           hoverStyles.push({
-            backgroundColor: t.palette.contrast_100,
+            backgroundColor: t.palette.contrast_50,
           })
         } else {
           baseStyles.push({
-            backgroundColor: t.palette.contrast_200,
+            backgroundColor: t.palette.contrast_100,
           })
         }
       } else if (variant === 'outline') {
@@ -308,12 +309,6 @@ export function Button({
     return {
       baseStyles,
       hoverStyles,
-      focusStyles: [
-        ...hoverStyles,
-        {
-          outline: 0,
-        } as ViewStyle,
-      ],
     }
   }, [t, variant, color, size, shape, disabled])
 
@@ -376,10 +371,8 @@ export function Button({
         a.flex_row,
         a.align_center,
         a.justify_center,
-        a.justify_center,
         flattenedBaseStyles,
         ...(state.hovered || state.pressed ? hoverStyles : []),
-        ...(state.focused ? focusStyles : []),
         flatten(style),
       ]}
       onPressIn={onPressIn}
@@ -398,7 +391,7 @@ export function Button({
           ]}>
           <LinearGradient
             colors={
-              state.hovered || state.pressed || state.focused
+              state.hovered || state.pressed
                 ? gradientHoverColors
                 : gradientColors
             }
@@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
   const textStyles = useSharedButtonTextStyles()
 
   return (
-    <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
+    <Text
+      {...rest}
+      style={normalizeTextStyles([
+        a.font_bold,
+        a.text_center,
+        textStyles,
+        style,
+      ])}>
       {children}
     </Text>
   )
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 3a7f73342..038f6295a 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -99,7 +99,7 @@ export function Outer({
                     style={[
                       web(a.fixed),
                       a.inset_0,
-                      {opacity: 0.5, backgroundColor: t.palette.black},
+                      {opacity: 0.8, backgroundColor: t.palette.black},
                     ]}
                   />
                 )}
diff --git a/src/components/GradientFill.tsx b/src/components/GradientFill.tsx
new file mode 100644
index 000000000..dc14aa72b
--- /dev/null
+++ b/src/components/GradientFill.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+import LinearGradient from 'react-native-linear-gradient'
+
+import {atoms as a, tokens} from '#/alf'
+
+export function GradientFill({
+  gradient,
+}: {
+  gradient:
+    | typeof tokens.gradients.sky
+    | typeof tokens.gradients.midnight
+    | typeof tokens.gradients.sunrise
+    | typeof tokens.gradients.sunset
+    | typeof tokens.gradients.bonfire
+    | typeof tokens.gradients.summer
+    | typeof tokens.gradients.nordic
+}) {
+  return (
+    <LinearGradient
+      colors={gradient.values.map(c => c[1])}
+      locations={gradient.values.map(c => c[0])}
+      start={{x: 0, y: 0}}
+      end={{x: 1, y: 1}}
+      style={[a.absolute, a.inset_0]}
+    />
+  )
+}
diff --git a/src/components/LabelingServiceCard/index.tsx b/src/components/LabelingServiceCard/index.tsx
new file mode 100644
index 000000000..6d0613511
--- /dev/null
+++ b/src/components/LabelingServiceCard/index.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {RichText} from '#/components/RichText'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {pluralize} from '#/lib/strings/helpers'
+
+type LabelingServiceProps = {
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+}
+
+export function Outer({
+  children,
+  style,
+}: React.PropsWithChildren<ViewStyleProp>) {
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.gap_md,
+        a.w_full,
+        a.p_lg,
+        a.pr_md,
+        a.overflow_hidden,
+        style,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function Avatar({avatar}: {avatar?: string}) {
+  return <UserAvatar type="labeler" size={40} avatar={avatar} />
+}
+
+export function Title({value}: {value: string}) {
+  return <Text style={[a.text_md, a.font_bold]}>{value}</Text>
+}
+
+export function Description({value, handle}: {value?: string; handle: string}) {
+  return value ? (
+    <Text numberOfLines={2}>
+      <RichText value={value} style={[]} />
+    </Text>
+  ) : (
+    <Text>
+      <Trans>By {sanitizeHandle(handle, '@')}</Trans>
+    </Text>
+  )
+}
+
+export function LikeCount({count}: {count: number}) {
+  const t = useTheme()
+  return (
+    <Text
+      style={[
+        a.mt_sm,
+        a.text_sm,
+        t.atoms.text_contrast_medium,
+        {fontWeight: '500'},
+      ]}>
+      <Trans>
+        Liked by {count} {pluralize(count, 'user')}
+      </Trans>
+    </Text>
+  )
+}
+
+export function Content({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.flex_row,
+        a.gap_md,
+        a.align_center,
+        a.justify_between,
+      ]}>
+      <View style={[a.gap_xs, a.flex_1]}>{children}</View>
+
+      <ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
+    </View>
+  )
+}
+
+/**
+ * The canonical view for a labeling service. Use this or compose your own.
+ */
+export function Default({
+  labeler,
+  style,
+}: LabelingServiceProps & ViewStyleProp) {
+  return (
+    <Outer style={style}>
+      <Avatar />
+      <Content>
+        <Title
+          value={getLabelingServiceTitle({
+            displayName: labeler.creator.displayName,
+            handle: labeler.creator.handle,
+          })}
+        />
+        <Description
+          value={labeler.creator.description}
+          handle={labeler.creator.handle}
+        />
+        {labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null}
+      </Content>
+    </Outer>
+  )
+}
+
+export function Link({
+  children,
+  labeler,
+}: LabelingServiceProps & Pick<LinkProps, 'children'>) {
+  const {_} = useLingui()
+
+  return (
+    <InternalLink
+      to={{
+        screen: 'Profile',
+        params: {
+          name: labeler.creator.handle,
+        },
+      }}
+      label={_(
+        msg`View the labeling service provided by @${labeler.creator.handle}`,
+      )}>
+      {children}
+    </InternalLink>
+  )
+}
+
+// TODO not finished yet
+export function DefaultSkeleton() {
+  return (
+    <View>
+      <Text>Loading</Text>
+    </View>
+  )
+}
+
+export function Loader({
+  did,
+  loading: LoadingComponent = DefaultSkeleton,
+  error: ErrorComponent,
+  component: Component,
+}: {
+  did: string
+  loading?: React.ComponentType<{}>
+  error?: React.ComponentType<{error: string}>
+  component: React.ComponentType<{
+    labeler: AppBskyLabelerDefs.LabelerViewDetailed
+  }>
+}) {
+  const {isLoading, data, error} = useLabelerInfoQuery({did})
+
+  return isLoading ? (
+    LoadingComponent ? (
+      <LoadingComponent />
+    ) : null
+  ) : error || !data ? (
+    ErrorComponent ? (
+      <ErrorComponent error={error?.message || 'Unknown error'} />
+    ) : null
+  ) : (
+    <Component labeler={data} />
+  )
+}
diff --git a/src/components/LikedByList.tsx b/src/components/LikedByList.tsx
new file mode 100644
index 000000000..bd1213639
--- /dev/null
+++ b/src/components/LikedByList.tsx
@@ -0,0 +1,109 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {logger} from '#/logger'
+import {List} from '#/view/com/util/List'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {ListFooter} from '#/components/Lists'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function LikedByList({uri}: {uri: string}) {
+  const t = useTheme()
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetching: isFetchingResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isRefetching,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error: likedByError,
+    refetch,
+  } = useLikedByQuery(resolvedUri?.uri)
+  const likes = React.useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+    return []
+  }, [data])
+  const initialNumToRender = useInitialNumToRender()
+  const error = resolveError || likedByError
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh likes', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {message: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
+    return (
+      <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
+    )
+  }, [])
+
+  if (isFetchingResolvedUri || !isFetched) {
+    return (
+      <View style={[a.w_full, a.align_center, a.p_lg]}>
+        <Loader size="xl" />
+      </View>
+    )
+  }
+
+  return likes.length ? (
+    <List
+      data={likes}
+      keyExtractor={item => item.actor.did}
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
+      onEndReached={onEndReached}
+      onEndReachedThreshold={3}
+      renderItem={renderItem}
+      initialNumToRender={initialNumToRender}
+      ListFooterComponent={() => (
+        <ListFooter
+          isFetching={isFetching && !isRefetching}
+          isError={isError}
+          error={error ? error.toString() : undefined}
+          onRetry={fetchNextPage}
+        />
+      )}
+    />
+  ) : (
+    <View style={[a.p_lg]}>
+      <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
+        <Text style={[a.text_md, a.leading_snug]}>
+          <Trans>
+            Nobody has liked this yet. Maybe you should be the first!
+          </Trans>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx
new file mode 100644
index 000000000..94a3f27e2
--- /dev/null
+++ b/src/components/LikesDialog.tsx
@@ -0,0 +1,131 @@
+import React, {useMemo, useCallback} from 'react'
+import {ActivityIndicator, FlatList, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
+
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {Loader} from '#/components/Loader'
+
+interface LikesDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  uri: string
+}
+
+export function LikesDialog(props: LikesDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <LikesDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+export function LikesDialogInner({control, uri}: LikesDialogProps) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {
+    data: resolvedUri,
+    error: resolveError,
+    isFetched: hasFetchedResolvedUri,
+  } = useResolveUriQuery(uri)
+  const {
+    data,
+    isFetching: isFetchingLikedBy,
+    isFetched: hasFetchedLikedBy,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error: likedByError,
+  } = useLikedByQuery(resolvedUri?.uri)
+
+  const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
+  const likes = useMemo(() => {
+    if (data?.pages) {
+      return data.pages.flatMap(page => page.likes)
+    }
+    return []
+  }, [data])
+
+  const onEndReached = useCallback(async () => {
+    if (isFetchingLikedBy || !hasNextPage || isError) return
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more likes', {message: err})
+    }
+  }, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
+
+  const renderItem = useCallback(
+    ({item}: {item: GetLikes.Like}) => {
+      return (
+        <ProfileCardWithFollowBtn
+          key={item.actor.did}
+          profile={item.actor}
+          onPress={() => control.close()}
+        />
+      )
+    },
+    [control],
+  )
+
+  return (
+    <Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
+      <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
+        <Trans>Liked by</Trans>
+      </Text>
+
+      {isLoading ? (
+        <View style={{minHeight: 300}}>
+          <Loader size="xl" />
+        </View>
+      ) : resolveError || likedByError || !data ? (
+        <ErrorMessage message={cleanError(resolveError || likedByError)} />
+      ) : likes.length === 0 ? (
+        <View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
+          <Text style={[a.text_center]}>
+            <Trans>
+              Nobody has liked this yet. Maybe you should be the first!
+            </Trans>
+          </Text>
+        </View>
+      ) : (
+        <FlatList
+          data={likes}
+          keyExtractor={item => item.actor.did}
+          onEndReached={onEndReached}
+          renderItem={renderItem}
+          initialNumToRender={15}
+          ListFooterComponent={
+            <ListFooterComponent isFetching={isFetchingNextPage} />
+          }
+        />
+      )}
+
+      <Dialog.Close />
+    </Dialog.Inner>
+  )
+}
+
+function ListFooterComponent({isFetching}: {isFetching: boolean}) {
+  if (isFetching) {
+    return (
+      <View style={a.pt_lg}>
+        <ActivityIndicator />
+      </View>
+    )
+  }
+  return null
+}
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 00e6a56f4..7d0e83332 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -251,7 +251,7 @@ export function InlineLink({
     onIn: onPressIn,
     onOut: onPressOut,
   } = useInteractionState()
-  const flattenedStyle = flatten(style)
+  const flattenedStyle = flatten(style) || {}
 
   return (
     <Text
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index bb0d24797..8a889c15e 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -33,7 +33,7 @@ export function ListFooter({
         a.border_t,
         a.pb_lg,
         t.atoms.border_contrast_low,
-        {height: 100},
+        {height: 180},
       ]}>
       {isFetching ? (
         <Loader size="xl" />
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index f4b03f680..60b234203 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
         style={flatten([
           a.flex_row,
           a.align_center,
-          a.gap_sm,
+          a.gap_lg,
           a.py_sm,
           a.rounded_xs,
           {minHeight: 32, paddingHorizontal: 10},
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
new file mode 100644
index 000000000..8ae0b52ec
--- /dev/null
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -0,0 +1,183 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
+import {DMCA_LINK} from '#/components/ReportDialog/const'
+import {Link} from '#/components/Link'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {
+  Button,
+  ButtonIcon,
+  ButtonText,
+  useButtonContext,
+} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
+
+import {ReportDialogProps} from './types'
+
+export function SelectReportOptionView({
+  ...props
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  onSelectReportOption: (reportOption: ReportOption) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const allReportOptions = useReportOptions()
+  const reportOptions = allReportOptions[props.params.type]
+
+  const i18n = React.useMemo(() => {
+    let title = _(msg`Report this content`)
+    let description = _(msg`Why should this content be reviewed?`)
+
+    if (props.params.type === 'account') {
+      title = _(msg`Report this user`)
+      description = _(msg`Why should this user be reviewed?`)
+    } else if (props.params.type === 'post') {
+      title = _(msg`Report this post`)
+      description = _(msg`Why should this post be reviewed?`)
+    } else if (props.params.type === 'list') {
+      title = _(msg`Report this list`)
+      description = _(msg`Why should this list be reviewed?`)
+    } else if (props.params.type === 'feedgen') {
+      title = _(msg`Report this feed`)
+      description = _(msg`Why should this feed be reviewed?`)
+    }
+
+    return {
+      title,
+      description,
+    }
+  }, [_, props.params.type])
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.justify_center, a.gap_sm]}>
+        <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {i18n.description}
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
+        {reportOptions.map(reportOption => {
+          return (
+            <Button
+              key={reportOption.reason}
+              label={_(msg`Create report for ${reportOption.title}`)}
+              onPress={() => props.onSelectReportOption(reportOption)}>
+              <ReportOptionButton
+                title={reportOption.title}
+                description={reportOption.description}
+              />
+            </Button>
+          )
+        })}
+
+        {(props.params.type === 'post' || props.params.type === 'account') && (
+          <View style={[a.pt_md, a.px_md]}>
+            <View
+              style={[
+                a.flex_row,
+                a.align_center,
+                a.justify_between,
+                a.gap_lg,
+                a.p_md,
+                a.pl_lg,
+                a.rounded_md,
+                t.atoms.bg_contrast_900,
+              ]}>
+              <Text
+                style={[
+                  a.flex_1,
+                  t.atoms.text_inverted,
+                  a.italic,
+                  a.leading_snug,
+                ]}>
+                <Trans>Need to report a copyright violation?</Trans>
+              </Text>
+              <Link
+                to={DMCA_LINK}
+                label={_(msg`View details for reporting a copyright violation`)}
+                size="small"
+                variant="solid"
+                color="secondary">
+                <ButtonText>
+                  <Trans>View details</Trans>
+                </ButtonText>
+                <ButtonIcon position="right" icon={SquareArrowTopRight} />
+              </Link>
+            </View>
+          </View>
+        )}
+      </View>
+    </View>
+  )
+}
+
+function ReportOptionButton({
+  title,
+  description,
+}: {
+  title: string
+  description: string
+}) {
+  const t = useTheme()
+  const {hovered, pressed} = useButtonContext()
+  const interacted = hovered || pressed
+
+  const styles = React.useMemo(() => {
+    return {
+      interacted: {
+        backgroundColor: t.palette.contrast_50,
+      },
+    }
+  }, [t])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.p_md,
+        a.rounded_md,
+        {paddingRight: 70},
+        interacted && styles.interacted,
+      ]}>
+      <View style={[a.flex_1, a.gap_xs]}>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          {title}
+        </Text>
+        <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
+      </View>
+
+      <View
+        style={[
+          a.absolute,
+          a.inset_0,
+          a.justify_center,
+          a.pr_md,
+          {left: 'auto'},
+        ]}>
+        <ChevronRight
+          size="md"
+          fill={
+            hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
+          }
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
new file mode 100644
index 000000000..99af64a2a
--- /dev/null
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -0,0 +1,262 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+
+import {atoms as a, useTheme, native} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import * as Toggle from '#/components/forms/Toggle'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/view/com/util/Toast'
+
+import {ReportDialogProps} from './types'
+import {getAgent} from '#/state/session'
+
+export function SubmitView({
+  params,
+  labelers,
+  selectedReportOption,
+  goBack,
+  onSubmitComplete,
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  selectedReportOption: ReportOption
+  goBack: () => void
+  onSubmitComplete: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [details, setDetails] = React.useState<string>('')
+  const [submitting, setSubmitting] = React.useState<boolean>(false)
+  const [selectedServices, setSelectedServices] = React.useState<string[]>(
+    labelers?.map(labeler => labeler.creator.did) || [],
+  )
+  const [error, setError] = React.useState('')
+
+  const submit = React.useCallback(async () => {
+    setSubmitting(true)
+    setError('')
+
+    const $type =
+      params.type === 'account'
+        ? 'com.atproto.admin.defs#repoRef'
+        : 'com.atproto.repo.strongRef'
+    const report = {
+      reasonType: selectedReportOption.reason,
+      subject: {
+        $type,
+        ...params,
+      },
+      reason: details,
+    }
+    const results = await Promise.all(
+      selectedServices.map(did =>
+        getAgent()
+          .withProxy('atproto_labeler', did)
+          .createModerationReport(report)
+          .then(
+            _ => true,
+            _ => false,
+          ),
+      ),
+    )
+
+    setSubmitting(false)
+
+    if (results.includes(true)) {
+      Toast.show(_(msg`Thank you. Your report has been sent.`))
+      onSubmitComplete()
+    } else {
+      setError(
+        _(
+          msg`There was an issue sending your report. Please check your internet connection.`,
+        ),
+      )
+    }
+  }, [
+    _,
+    params,
+    details,
+    selectedReportOption,
+    selectedServices,
+    onSubmitComplete,
+    setError,
+  ])
+
+  return (
+    <View style={[a.gap_2xl]}>
+      <Button
+        size="small"
+        variant="solid"
+        color="secondary"
+        shape="round"
+        label={_(msg`Go back to previous step`)}
+        onPress={goBack}>
+        <ButtonIcon icon={ChevronLeft} />
+      </Button>
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.p_md,
+          a.rounded_md,
+          a.border,
+          t.atoms.border_contrast_low,
+        ]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <Text style={[a.text_md, a.font_bold]}>
+            {selectedReportOption.title}
+          </Text>
+          <Text style={[a.leading_tight, {maxWidth: 400}]}>
+            {selectedReportOption.description}
+          </Text>
+        </View>
+
+        <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
+      </View>
+
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Select the moderation service(s) to report to</Trans>
+        </Text>
+
+        <Toggle.Group
+          label="Select mod services"
+          values={selectedServices}
+          onChange={setSelectedServices}>
+          <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+            {labelers.map(labeler => {
+              const title = getLabelingServiceTitle({
+                displayName: labeler.creator.displayName,
+                handle: labeler.creator.handle,
+              })
+              return (
+                <Toggle.Item
+                  key={labeler.creator.did}
+                  name={labeler.creator.did}
+                  label={title}>
+                  <LabelerToggle title={title} />
+                </Toggle.Item>
+              )
+            })}
+          </View>
+        </Toggle.Group>
+      </View>
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Optionally provide additional information below:</Trans>
+        </Text>
+
+        <View style={[a.relative, a.w_full]}>
+          <Dialog.Input
+            multiline
+            value={details}
+            onChangeText={setDetails}
+            label="Text field"
+            style={{paddingRight: 60}}
+            numberOfLines={6}
+          />
+
+          <View
+            style={[
+              a.absolute,
+              a.flex_row,
+              a.align_center,
+              a.pr_md,
+              a.pb_sm,
+              {
+                bottom: 0,
+                right: 0,
+              },
+            ]}>
+            <CharProgress count={details?.length || 0} />
+          </View>
+        </View>
+      </View>
+
+      <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
+        {!selectedServices.length ||
+          (error && (
+            <Text
+              style={[
+                a.flex_1,
+                a.italic,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              {error ? (
+                error
+              ) : (
+                <Trans>You must select at least one labeler for a report</Trans>
+              )}
+            </Text>
+          ))}
+
+        <Button
+          size="large"
+          variant="solid"
+          color="negative"
+          label={_(msg`Send report`)}
+          onPress={submit}
+          disabled={!selectedServices.length}>
+          <ButtonText>
+            <Trans>Send report</Trans>
+          </ButtonText>
+          {submitting && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+function LabelerToggle({title}: {title: string}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_md,
+        a.p_md,
+        a.pr_lg,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+        ctx.selected && [t.atoms.bg_contrast_50],
+      ]}>
+      <Toggle.Checkbox />
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.z_10,
+        ]}>
+        <Text
+          style={[
+            native({marginTop: 2}),
+            t.atoms.text_contrast_medium,
+            ctx.selected && t.atoms.text,
+          ]}>
+          {title}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts
new file mode 100644
index 000000000..30c9aff88
--- /dev/null
+++ b/src/components/ReportDialog/const.ts
@@ -0,0 +1 @@
+export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx
new file mode 100644
index 000000000..b35727c7d
--- /dev/null
+++ b/src/components/ReportDialog/index.tsx
@@ -0,0 +1,73 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {useMyLabelersQuery} from '#/state/queries/preferences'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a} from '#/alf'
+import {Loader} from '#/components/Loader'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+
+import {ReportDialogProps} from './types'
+import {SelectReportOptionView} from './SelectReportOptionView'
+import {SubmitView} from './SubmitView'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+
+export function ReportDialog(props: ReportDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <ReportDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ReportDialogInner(props: ReportDialogProps) {
+  const {
+    isLoading: isLabelerLoading,
+    data: labelers,
+    error,
+  } = useMyLabelersQuery()
+  const isLoading = useDelayedLoading(500, isLabelerLoading)
+  const [selectedReportOption, setSelectedReportOption] = React.useState<
+    ReportOption | undefined
+  >()
+
+  return (
+    <Dialog.ScrollableInner label="Report Dialog">
+      {isLoading ? (
+        <View style={[a.align_center, {height: 100}]}>
+          <Loader size="xl" />
+          {/* Here to capture focus for a hot sec to prevent flash */}
+          <Pressable accessible={false} />
+        </View>
+      ) : error || !labelers ? (
+        <View>
+          <Text style={[a.text_md]}>
+            <Trans>Something went wrong, please try again.</Trans>
+          </Text>
+        </View>
+      ) : selectedReportOption ? (
+        <SubmitView
+          {...props}
+          labelers={labelers}
+          selectedReportOption={selectedReportOption}
+          goBack={() => setSelectedReportOption(undefined)}
+          onSubmitComplete={() => props.control.close()}
+        />
+      ) : (
+        <SelectReportOptionView
+          {...props}
+          labelers={labelers}
+          onSelectReportOption={setSelectedReportOption}
+        />
+      )}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
new file mode 100644
index 000000000..0c8a1e077
--- /dev/null
+++ b/src/components/ReportDialog/types.ts
@@ -0,0 +1,15 @@
+import * as Dialog from '#/components/Dialog'
+
+export type ReportDialogProps = {
+  control: Dialog.DialogOuterProps['control']
+  params:
+    | {
+        type: 'post' | 'list' | 'feedgen' | 'other'
+        uri: string
+        cid: string
+      }
+    | {
+        type: 'account'
+        did: string
+      }
+}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 849a3f42d..13e4e7d6b 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -59,7 +59,7 @@ export function TagMenu({
   const displayTag = '#' + tag
 
   const isMuted = Boolean(
-    (preferences?.mutedWords?.find(
+    (preferences?.moderationPrefs.mutedWords?.find(
       m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 8245bd019..b2f5c9075 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -50,7 +50,7 @@ export function TagMenu({
   const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
     useRemoveMutedWordMutation()
   const isMuted = Boolean(
-    (preferences?.mutedWords?.find(
+    (preferences?.moderationPrefs.mutedWords?.find(
       m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx
index 5268e7f46..f8b3ad1bd 100644
--- a/src/components/Typography.tsx
+++ b/src/components/Typography.tsx
@@ -1,5 +1,10 @@
 import React from 'react'
-import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
+import {
+  Text as RNText,
+  StyleProp,
+  TextStyle,
+  TextProps as RNTextProps,
+} from 'react-native'
 import {UITextView} from 'react-native-ui-text-view'
 
 import {useTheme, atoms, web, flatten} from '#/alf'
@@ -34,7 +39,7 @@ export function leading<
  * If the `lineHeight` value is > 2, we assume it's an absolute value and
  * returns it as-is.
  */
-function normalizeTextStyles(styles: TextStyle[]) {
+export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
   const s = flatten(styles)
   // should always be defined on these components
   const fontSize = s.fontSize || atoms.text_md.fontSize
diff --git a/src/components/dialogs/Context.tsx b/src/components/dialogs/Context.tsx
index d86c90a92..87bd5c2ed 100644
--- a/src/components/dialogs/Context.tsx
+++ b/src/components/dialogs/Context.tsx
@@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   const mutedWordsDialogControl = Dialog.useDialogControl()
-  const ctx = React.useMemo(
+  const ctx = React.useMemo<ControlsContext>(
     () => ({mutedWordsDialogControl}),
     [mutedWordsDialogControl],
   )
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index dcdaccea3..46f319adf 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
                 </Trans>
               </Text>
             </View>
-          ) : preferences.mutedWords.length ? (
-            [...preferences.mutedWords]
+          ) : preferences.moderationPrefs.mutedWords.length ? (
+            [...preferences.moderationPrefs.mutedWords]
               .reverse()
               .map((word, i) => (
                 <MutedWordRow
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index a83f92a2a..7a4b5ac95 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -2,7 +2,14 @@ import React from 'react'
 import {Pressable, View, ViewStyle} from 'react-native'
 
 import {HITSLOP_10} from 'lib/constants'
-import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
+import {
+  useTheme,
+  atoms as a,
+  native,
+  flatten,
+  ViewStyleProp,
+  TextStyleProp,
+} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
@@ -220,20 +227,17 @@ export function Item({
         onPressOut={onPressOut}
         onFocus={onFocus}
         onBlur={onBlur}
-        style={[
-          a.flex_row,
-          a.align_center,
-          a.gap_sm,
-          focused ? web({outline: 'none'}) : {},
-          flatten(style),
-        ]}>
+        style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
         {typeof children === 'function' ? children(state) : children}
       </Pressable>
     </ItemContext.Provider>
   )
 }
 
-export function Label({children}: React.PropsWithChildren<{}>) {
+export function Label({
+  children,
+  style,
+}: React.PropsWithChildren<TextStyleProp>) {
   const t = useTheme()
   const {disabled} = useItemContext()
   return (
@@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
         a.font_bold,
         {
           userSelect: 'none',
-          color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
+          color: disabled
+            ? t.atoms.text_contrast_low.color
+            : t.atoms.text_contrast_high.color,
         },
         native({
           paddingTop: 3,
         }),
+        flatten(style),
       ]}>
       {children}
     </Text>
@@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
 export function createSharedToggleStyles({
   theme: t,
   hovered,
-  focused,
   selected,
   disabled,
   isInvalid,
@@ -280,7 +286,7 @@ export function createSharedToggleStyles({
       borderColor: t.palette.primary_500,
     })
 
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
@@ -289,7 +295,7 @@ export function createSharedToggleStyles({
       })
     }
   } else {
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
@@ -306,7 +312,7 @@ export function createSharedToggleStyles({
         t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
     })
 
-    if (hovered || focused) {
+    if (hovered) {
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
@@ -353,7 +359,7 @@ export function Checkbox() {
           width: 20,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
       {selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
     </View>
@@ -385,7 +391,7 @@ export function Switch() {
           width: 30,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
       <View
         style={[
@@ -437,7 +443,7 @@ export function Radio() {
           width: 20,
         },
         baseStyles,
-        hovered || focused ? baseHoverStyles : {},
+        hovered ? baseHoverStyles : {},
       ]}>
       {selected ? (
         <View
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 7e1bd70b9..9cdaaaa9d 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
 
 export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
   AccessibilityProps &
-  React.PropsWithChildren<{testID?: string}>
+  React.PropsWithChildren<{
+    testID?: string
+  }>
 
 export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
   multiple?: boolean
@@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
         native({
           paddingBottom: 10,
         }),
-        a.px_sm,
+        a.px_md,
         t.atoms.bg,
         t.atoms.border_contrast_low,
         baseStyles,
         activeStyles,
-        (state.hovered || state.focused || state.pressed) && hoverStyles,
+        (state.hovered || state.pressed) && hoverStyles,
       ]}>
       {typeof children === 'string' ? (
         <Text
diff --git a/src/components/hooks/useDelayedLoading.ts b/src/components/hooks/useDelayedLoading.ts
new file mode 100644
index 000000000..6c7e2ede0
--- /dev/null
+++ b/src/components/hooks/useDelayedLoading.ts
@@ -0,0 +1,15 @@
+import React from 'react'
+
+export function useDelayedLoading(delay: number, initialState: boolean = true) {
+  const [isLoading, setIsLoading] = React.useState(initialState)
+
+  React.useEffect(() => {
+    let timeout: NodeJS.Timeout
+    // on initial load, show a loading spinner for a hot sec to prevent flash
+    if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
+
+    return () => timeout && clearTimeout(timeout)
+  }, [isLoading, delay])
+
+  return isLoading
+}
diff --git a/src/components/icons/ArrowTriangle.tsx b/src/components/icons/ArrowTriangle.tsx
new file mode 100644
index 000000000..b27b719ae
--- /dev/null
+++ b/src/components/icons/ArrowTriangle.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
+  path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
+})
diff --git a/src/components/icons/Bars.tsx b/src/components/icons/Bars.tsx
new file mode 100644
index 000000000..7b1415a4b
--- /dev/null
+++ b/src/components/icons/Bars.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
+})
diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx
index b1a9deea0..a04e6e009 100644
--- a/src/components/icons/Chevron.tsx
+++ b/src/components/icons/Chevron.tsx
@@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
 export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
   path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
 })
+
+export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
+})
+
+export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/CircleBanSign.tsx b/src/components/icons/CircleBanSign.tsx
new file mode 100644
index 000000000..543985d43
--- /dev/null
+++ b/src/components/icons/CircleBanSign.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
+})
diff --git a/src/components/icons/Gear.tsx b/src/components/icons/Gear.tsx
new file mode 100644
index 000000000..980b7413b
--- /dev/null
+++ b/src/components/icons/Gear.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
+})
diff --git a/src/components/icons/Group.tsx b/src/components/icons/Group.tsx
new file mode 100644
index 000000000..9e5ab8893
--- /dev/null
+++ b/src/components/icons/Group.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
+})
diff --git a/src/components/icons/Group3.tsx b/src/components/icons/Group3.tsx
deleted file mode 100644
index 9e5ab8893..000000000
--- a/src/components/icons/Group3.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import {createSinglePathSVG} from './TEMPLATE'
-
-export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
-  path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
-})
diff --git a/src/components/icons/RaisingHand.tsx b/src/components/icons/RaisingHand.tsx
new file mode 100644
index 000000000..cd023cb7e
--- /dev/null
+++ b/src/components/icons/RaisingHand.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
+})
diff --git a/src/components/icons/Shield.tsx b/src/components/icons/Shield.tsx
new file mode 100644
index 000000000..5038d5c24
--- /dev/null
+++ b/src/components/icons/Shield.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
+})
diff --git a/src/components/icons/SquareArrowTopRight.tsx b/src/components/icons/SquareArrowTopRight.tsx
new file mode 100644
index 000000000..7701e26e5
--- /dev/null
+++ b/src/components/icons/SquareArrowTopRight.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
+})
diff --git a/src/components/icons/SquareBehindSquare4.tsx b/src/components/icons/SquareBehindSquare4.tsx
new file mode 100644
index 000000000..425599cbc
--- /dev/null
+++ b/src/components/icons/SquareBehindSquare4.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
+})
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
new file mode 100644
index 000000000..1e8f36d31
--- /dev/null
+++ b/src/components/moderation/ContentHider.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {isJustAMute} from '#/lib/moderation'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+
+import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ContentHider({
+  testID,
+  modui,
+  ignoreMute,
+  style,
+  childContainerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  modui: ModerationUI | undefined
+  ignoreMute?: boolean
+  style?: StyleProp<ViewStyle>
+  childContainerStyle?: StyleProp<ViewStyle>
+}>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const [override, setOverride] = React.useState(false)
+  const control = useModerationDetailsDialogControl()
+
+  const blur = modui?.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur || (ignoreMute && isJustAMute(modui))) {
+    return (
+      <View testID={testID} style={[styles.outer, style]}>
+        {children}
+      </View>
+    )
+  }
+
+  return (
+    <View testID={testID} style={[a.overflow_hidden, style]}>
+      <ModerationDetailsDialog control={control} modcause={blur} />
+
+      <Button
+        onPress={() => {
+          if (!modui.noOverride) {
+            setOverride(v => !v)
+          } else {
+            control.open()
+          }
+        }}
+        label={desc.name}
+        accessibilityHint={
+          modui.noOverride
+            ? _(msg`Learn more about the moderation applied to this content.`)
+            : override
+            ? _(msg`Hide the content`)
+            : _(msg`Show the content`)
+        }>
+        {state => (
+          <View
+            style={[
+              a.flex_row,
+              a.w_full,
+              a.justify_start,
+              a.align_center,
+              a.py_md,
+              a.px_lg,
+              a.gap_xs,
+              a.rounded_sm,
+              t.atoms.bg_contrast_25,
+              gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl],
+              (state.hovered || state.pressed) && t.atoms.bg_contrast_50,
+            ]}>
+            <desc.icon
+              size="md"
+              fill={t.atoms.text_contrast_medium.color}
+              style={{marginLeft: -2}}
+            />
+            <Text
+              style={[
+                a.flex_1,
+                a.text_left,
+                a.font_bold,
+                a.leading_snug,
+                gtMobile && [a.font_semibold],
+                t.atoms.text_contrast_medium,
+                web({
+                  marginBottom: 1,
+                }),
+              ]}>
+              {desc.name}
+            </Text>
+            {!modui.noOverride && (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.leading_snug,
+                  gtMobile && [a.font_semibold],
+                  t.atoms.text_contrast_high,
+                  web({
+                    marginBottom: 1,
+                  }),
+                ]}>
+                {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
+              </Text>
+            )}
+          </View>
+        )}
+      </Button>
+
+      {desc.source && blur.type === 'label' && !override && (
+        <Button
+          onPress={() => {
+            control.open()
+          }}
+          label={_(
+            msg`Learn more about the moderation applied to this content.`,
+          )}
+          style={[a.pt_sm]}>
+          {state => (
+            <Text
+              style={[
+                a.flex_1,
+                a.text_sm,
+                a.font_normal,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+                a.text_left,
+              ]}>
+              {desc.sourceType === 'user' ? (
+                <Trans>Labeled by the author.</Trans>
+              ) : (
+                <Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans>
+              )}{' '}
+              <Text
+                style={[
+                  {color: t.palette.primary_500},
+                  a.text_sm,
+                  state.hovered && [web({textDecoration: 'underline'})],
+                ]}>
+                <Trans>Learn more.</Trans>
+              </Text>
+            </Text>
+          )}
+        </Button>
+      )}
+
+      {override && <View style={childContainerStyle}>{children}</View>}
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    overflow: 'hidden',
+  },
+  cover: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    borderRadius: 8,
+    marginTop: 4,
+    paddingVertical: 14,
+    paddingLeft: 14,
+    paddingRight: 18,
+  },
+  showBtn: {
+    marginLeft: 'auto',
+    alignSelf: 'center',
+  },
+})
diff --git a/src/components/moderation/GlobalModerationLabelPref.tsx b/src/components/moderation/GlobalModerationLabelPref.tsx
new file mode 100644
index 000000000..7633cb9f2
--- /dev/null
+++ b/src/components/moderation/GlobalModerationLabelPref.tsx
@@ -0,0 +1,93 @@
+import React from 'react'
+import {View} from 'react-native'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {
+  usePreferencesQuery,
+  usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+
+export function GlobalModerationLabelPref({
+  labelValueDefinition,
+  disabled,
+}: {
+  labelValueDefinition: InterpretedLabelValueDefinition
+  disabled?: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+
+  const {identifier} = labelValueDefinition
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const savedPref = preferences?.moderationPrefs.labels[identifier]
+  const pref = variables?.visibility ?? savedPref ?? 'warn'
+
+  const allLabelStrings = useGlobalLabelStrings()
+  const labelStrings =
+    labelValueDefinition.identifier in allLabelStrings
+      ? allLabelStrings[labelValueDefinition.identifier]
+      : {
+          name: labelValueDefinition.identifier,
+          description: `Labeled "${labelValueDefinition.identifier}"`,
+        }
+
+  const labelOptions = {
+    hide: _(msg`Hide`),
+    warn: _(msg`Warn`),
+    ignore: _(msg`Show`),
+  }
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.justify_between,
+        a.gap_sm,
+        a.py_md,
+        a.pl_lg,
+        a.pr_md,
+        a.align_center,
+      ]}>
+      <View style={[a.gap_xs, a.flex_1]}>
+        <Text style={[a.font_bold]}>{labelStrings.name}</Text>
+        <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+          {labelStrings.description}
+        </Text>
+      </View>
+      <View style={[a.justify_center, {minHeight: 35}]}>
+        {!disabled && (
+          <ToggleButton.Group
+            label={_(
+              msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
+            )}
+            values={[pref]}
+            onChange={newPref =>
+              mutate({
+                label: identifier,
+                visibility: newPref[0] as LabelPreference,
+                labelerDid: undefined,
+              })
+            }>
+            <ToggleButton.Button name="ignore" label={labelOptions.ignore}>
+              {labelOptions.ignore}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="warn" label={labelOptions.warn}>
+              {labelOptions.warn}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="hide" label={labelOptions.hide}>
+              {labelOptions.hide}
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+        )}
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
new file mode 100644
index 000000000..099769fa7
--- /dev/null
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSession} from '#/state/session'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {
+  LabelsOnMeDialog,
+  useLabelsOnMeDialogControl,
+} from '#/components/moderation/LabelsOnMeDialog'
+
+export function LabelsOnMe({
+  details,
+  labels,
+  size,
+  style,
+}: {
+  details: {did: string} | {uri: string; cid: string}
+  labels: ComAtprotoLabelDefs.Label[] | undefined
+  size?: ButtonSize
+  style?: StyleProp<ViewStyle>
+}) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const isAccount = 'did' in details
+  const control = useLabelsOnMeDialogControl()
+
+  if (!labels || !currentAccount) {
+    return null
+  }
+  labels = labels.filter(
+    l => !l.val.startsWith('!') && l.src !== currentAccount.did,
+  )
+  if (!labels.length) {
+    return null
+  }
+
+  const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
+  return (
+    <View style={[a.flex_row, style]}>
+      <LabelsOnMeDialog control={control} subject={details} labels={labels} />
+
+      <Button
+        variant="solid"
+        color="secondary"
+        size={size || 'small'}
+        label={_(msg`View information about these labels`)}
+        onPress={() => {
+          control.open()
+        }}>
+        <ButtonIcon position="left" icon={CircleInfo} />
+        <ButtonText style={[a.leading_snug]}>
+          {labels.length}{' '}
+          {labels.length === 1 ? (
+            <Trans>label has been placed on this {labelTarget}</Trans>
+          ) : (
+            <Trans>labels have been placed on this {labelTarget}</Trans>
+          )}
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
+
+export function LabelsOnMyPost({
+  post,
+  style,
+}: {
+  post: AppBskyFeedDefs.PostView
+  style?: StyleProp<ViewStyle>
+}) {
+  const {currentAccount} = useSession()
+  if (post.author.did !== currentAccount?.did) {
+    return null
+  }
+  return (
+    <LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
+  )
+}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
new file mode 100644
index 000000000..6eddbc7ce
--- /dev/null
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -0,0 +1,262 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
+import {makeProfileLink} from '#/lib/routes/links'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {getAgent} from '#/state/session'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonText} from '#/components/Button'
+import {InlineLink} from '#/components/Link'
+import * as Toast from '#/view/com/util/Toast'
+import {Divider} from '../Divider'
+
+export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
+
+type Subject =
+  | {
+      uri: string
+      cid: string
+    }
+  | {
+      did: string
+    }
+
+export interface LabelsOnMeDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  subject: Subject
+  labels: ComAtprotoLabelDefs.Label[]
+}
+
+export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
+  const {_} = useLingui()
+  const [appealingLabel, setAppealingLabel] = React.useState<
+    ComAtprotoLabelDefs.Label | undefined
+  >(undefined)
+  const {subject, labels} = props
+  const isAccount = 'did' in subject
+
+  return (
+    <Dialog.ScrollableInner
+      label={
+        isAccount
+          ? _(msg`The following labels were applied to your account.`)
+          : _(msg`The following labels were applied to your content.`)
+      }>
+      {appealingLabel ? (
+        <AppealForm
+          label={appealingLabel}
+          subject={subject}
+          control={props.control}
+          onPressBack={() => setAppealingLabel(undefined)}
+        />
+      ) : (
+        <>
+          <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+            {isAccount ? (
+              <Trans>Labels on your account</Trans>
+            ) : (
+              <Trans>Labels on your content</Trans>
+            )}
+          </Text>
+          <Text style={[a.text_md, a.leading_snug]}>
+            <Trans>
+              You may appeal these labels if you feel they were placed in error.
+            </Trans>
+          </Text>
+
+          <View style={[a.py_lg, a.gap_md]}>
+            {labels.map(label => (
+              <Label
+                key={`${label.val}-${label.src}`}
+                label={label}
+                control={props.control}
+                onPressAppeal={label => setAppealingLabel(label)}
+              />
+            ))}
+          </View>
+        </>
+      )}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <LabelsOnMeDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function Label({
+  label,
+  control,
+  onPressAppeal,
+}: {
+  label: ComAtprotoLabelDefs.Label
+  control: Dialog.DialogOuterProps['control']
+  onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {labeler, strings} = useLabelInfo(label)
+  return (
+    <View
+      style={[
+        a.border,
+        t.atoms.border_contrast_low,
+        a.rounded_sm,
+        a.overflow_hidden,
+      ]}>
+      <View style={[a.p_md, a.gap_sm, a.flex_row]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
+          <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+            {strings.description}
+          </Text>
+        </View>
+        <View>
+          <Button
+            variant="solid"
+            color="secondary"
+            size="small"
+            label={_(msg`Appeal`)}
+            onPress={() => onPressAppeal(label)}>
+            <ButtonText>
+              <Trans>Appeal</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </View>
+
+      <Divider />
+
+      <View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Source:</Trans>{' '}
+          <InlineLink
+            to={makeProfileLink(
+              labeler ? labeler.creator : {did: label.src, handle: ''},
+            )}
+            onPress={() => control.close()}>
+            {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+          </InlineLink>
+        </Text>
+      </View>
+    </View>
+  )
+}
+
+function AppealForm({
+  label,
+  subject,
+  control,
+  onPressBack,
+}: {
+  label: ComAtprotoLabelDefs.Label
+  subject: Subject
+  control: Dialog.DialogOuterProps['control']
+  onPressBack: () => void
+}) {
+  const {_} = useLingui()
+  const {labeler, strings} = useLabelInfo(label)
+  const {gtMobile} = useBreakpoints()
+  const [details, setDetails] = React.useState('')
+  const isAccountReport = 'did' in subject
+
+  const onSubmit = async () => {
+    try {
+      const $type = !isAccountReport
+        ? 'com.atproto.repo.strongRef'
+        : 'com.atproto.admin.defs#repoRef'
+      await getAgent()
+        .withProxy('atproto_labeler', label.src)
+        .createModerationReport({
+          reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+          subject: {
+            $type,
+            ...subject,
+          },
+          reason: details,
+        })
+      Toast.show(_(msg`Appeal submitted.`))
+    } finally {
+      control.close()
+    }
+  }
+
+  return (
+    <>
+      <Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
+        <Trans>Appeal "{strings.name}" label</Trans>
+      </Text>
+      <Text style={[a.text_md, a.leading_snug]}>
+        <Trans>
+          This appeal will be sent to{' '}
+          <InlineLink
+            to={makeProfileLink(
+              labeler ? labeler.creator : {did: label.src, handle: ''},
+            )}
+            onPress={() => control.close()}
+            style={[a.text_md, a.leading_snug]}>
+            {labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
+          </InlineLink>
+          .
+        </Trans>
+      </Text>
+      <View style={[a.my_md]}>
+        <Dialog.Input
+          label={_(msg`Text input field`)}
+          placeholder={_(
+            msg`Please explain why you think this label was incorrectly applied by ${
+              labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
+            }`,
+          )}
+          value={details}
+          onChangeText={setDetails}
+          autoFocus={true}
+          numberOfLines={3}
+          multiline
+          maxLength={300}
+        />
+      </View>
+
+      <View
+        style={
+          gtMobile
+            ? [a.flex_row, a.justify_between]
+            : [{flexDirection: 'column-reverse'}, a.gap_sm]
+        }>
+        <Button
+          testID="backBtn"
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}
+          label={_(msg`Back`)}>
+          {_(msg`Back`)}
+        </Button>
+        <Button
+          testID="submitBtn"
+          variant="solid"
+          color="primary"
+          size="medium"
+          onPress={onSubmit}
+          label={_(msg`Submit`)}>
+          {_(msg`Submit`)}
+        </Button>
+      </View>
+    </>
+  )
+}
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
new file mode 100644
index 000000000..da490cb43
--- /dev/null
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ModerationCause} from '@atproto/api'
+
+import {listUriToHref} from '#/lib/strings/url-helpers'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {makeProfileLink} from '#/lib/routes/links'
+
+import {isNative} from '#/platform/detection'
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {InlineLink} from '#/components/Link'
+import {Divider} from '#/components/Divider'
+
+export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
+
+export interface ModerationDetailsDialogProps {
+  control: Dialog.DialogOuterProps['control']
+  modcause: ModerationCause
+}
+
+export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+      <ModerationDetailsDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ModerationDetailsDialogInner({
+  modcause,
+  control,
+}: ModerationDetailsDialogProps & {
+  control: Dialog.DialogOuterProps['control']
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const desc = useModerationCauseDescription(modcause)
+
+  let name
+  let description
+  if (!modcause) {
+    name = _(msg`Content Warning`)
+    description = _(
+      msg`Moderator has chosen to set a general warning on the content.`,
+    )
+  } else if (modcause.type === 'blocking') {
+    if (modcause.source.type === 'list') {
+      const list = modcause.source.list
+      name = _(msg`User Blocked by List`)
+      description = (
+        <Trans>
+          This user is included in the{' '}
+          <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
+            {list.name}
+          </InlineLink>{' '}
+          list which you have blocked.
+        </Trans>
+      )
+    } else {
+      name = _(msg`User Blocked`)
+      description = _(
+        msg`You have blocked this user. You cannot view their content.`,
+      )
+    }
+  } else if (modcause.type === 'blocked-by') {
+    name = _(msg`User Blocks You`)
+    description = _(
+      msg`This user has blocked you. You cannot view their content.`,
+    )
+  } else if (modcause.type === 'block-other') {
+    name = _(msg`Content Not Available`)
+    description = _(
+      msg`This content is not available because one of the users involved has blocked the other.`,
+    )
+  } else if (modcause.type === 'muted') {
+    if (modcause.source.type === 'list') {
+      const list = modcause.source.list
+      name = _(msg`Account Muted by List`)
+      description = (
+        <Trans>
+          This user is included in the{' '}
+          <InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
+            {list.name}
+          </InlineLink>{' '}
+          list which you have muted.
+        </Trans>
+      )
+    } else {
+      name = _(msg`Account Muted`)
+      description = _(msg`You have muted this account.`)
+    }
+  } else if (modcause.type === 'mute-word') {
+    name = _(msg`Post Hidden by Muted Word`)
+    description = _(msg`You've chosen to hide a word or tag within this post.`)
+  } else if (modcause.type === 'hidden') {
+    name = _(msg`Post Hidden by You`)
+    description = _(msg`You have hidden this post.`)
+  } else if (modcause.type === 'label') {
+    name = desc.name
+    description = desc.description
+  } else {
+    // should never happen
+    name = ''
+    description = ''
+  }
+
+  return (
+    <Dialog.ScrollableInner label={_(msg`Moderation details`)}>
+      <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
+        {name}
+      </Text>
+      <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
+        {description}
+      </Text>
+
+      {modcause.type === 'label' && (
+        <>
+          <Divider />
+          <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
+            <Trans>
+              This label was applied by{' '}
+              {modcause.source.type === 'user' ? (
+                <Trans>the author</Trans>
+              ) : (
+                <InlineLink
+                  to={makeProfileLink({did: modcause.label.src, handle: ''})}
+                  onPress={() => control.close()}
+                  style={a.text_md}>
+                  {desc.source}
+                </InlineLink>
+              )}
+              .
+            </Trans>
+          </Text>
+        </>
+      )}
+
+      {isNative && <View style={{height: 40}} />}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/moderation/ModerationLabelPref.tsx b/src/components/moderation/ModerationLabelPref.tsx
new file mode 100644
index 000000000..f14550488
--- /dev/null
+++ b/src/components/moderation/ModerationLabelPref.tsx
@@ -0,0 +1,154 @@
+import React from 'react'
+import {View} from 'react-native'
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
+
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
+import {
+  usePreferencesQuery,
+  usePreferencesSetContentLabelMutation,
+} from '#/state/queries/preferences'
+import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+
+export function ModerationLabelPref({
+  labelValueDefinition,
+  labelerDid,
+  disabled,
+}: {
+  labelValueDefinition: InterpretedLabelValueDefinition
+  labelerDid: string | undefined
+  disabled?: boolean
+}) {
+  const {_, i18n} = useLingui()
+  const t = useTheme()
+
+  const isGlobalLabel = !labelValueDefinition.definedBy
+  const {identifier} = labelValueDefinition
+  const {data: preferences} = usePreferencesQuery()
+  const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const savedPref =
+    labelerDid && !isGlobalLabel
+      ? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
+          ?.labels[identifier]
+      : preferences?.moderationPrefs.labels[identifier]
+  const pref =
+    variables?.visibility ??
+    savedPref ??
+    labelValueDefinition.defaultSetting ??
+    'warn'
+
+  // does the 'warn' setting make sense for this label?
+  const canWarn = !(
+    labelValueDefinition.blurs === 'none' &&
+    labelValueDefinition.severity === 'none'
+  )
+  // is this label adult only?
+  const adultOnly = labelValueDefinition.flags.includes('adult')
+  // is this label disabled because it's adult only?
+  const adultDisabled =
+    adultOnly && !preferences?.moderationPrefs.adultContentEnabled
+  // are there any reasons we cant configure this label here?
+  const cantConfigure = isGlobalLabel || adultDisabled
+
+  // adjust the pref based on whether warn is available
+  let prefAdjusted = pref
+  if (adultDisabled) {
+    prefAdjusted = 'hide'
+  } else if (!canWarn && pref === 'warn') {
+    prefAdjusted = 'ignore'
+  }
+
+  // grab localized descriptions of the label and its settings
+  const currentPrefLabel = useLabelBehaviorDescription(
+    labelValueDefinition,
+    prefAdjusted,
+  )
+  const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
+  const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
+  const ignoreLabel = useLabelBehaviorDescription(
+    labelValueDefinition,
+    'ignore',
+  )
+  const globalLabelStrings = useGlobalLabelStrings()
+  const labelStrings = getLabelStrings(
+    i18n.locale,
+    globalLabelStrings,
+    labelValueDefinition,
+  )
+
+  return (
+    <View style={[a.flex_row, a.gap_sm, a.px_lg, a.py_lg, a.justify_between]}>
+      <View style={[a.gap_xs, a.flex_1]}>
+        <Text style={[a.font_bold]}>{labelStrings.name}</Text>
+        <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
+          {labelStrings.description}
+        </Text>
+
+        {cantConfigure && (
+          <View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
+            <CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
+
+            <Text
+              style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
+              {adultDisabled ? (
+                <Trans>Adult content is disabled.</Trans>
+              ) : isGlobalLabel ? (
+                <Trans>
+                  Configured in{' '}
+                  <InlineLink to="/moderation" style={a.text_sm}>
+                    moderation settings
+                  </InlineLink>
+                  .
+                </Trans>
+              ) : null}
+            </Text>
+          </View>
+        )}
+      </View>
+      {disabled ? (
+        <></>
+      ) : cantConfigure ? (
+        <View style={[{minHeight: 35}, a.px_sm, a.py_md]}>
+          <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+            {currentPrefLabel}
+          </Text>
+        </View>
+      ) : (
+        <View style={[{minHeight: 35}]}>
+          <ToggleButton.Group
+            label={_(
+              msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
+            )}
+            values={[prefAdjusted]}
+            onChange={newPref =>
+              mutate({
+                label: identifier,
+                visibility: newPref[0] as LabelPreference,
+                labelerDid,
+              })
+            }>
+            <ToggleButton.Button name="ignore" label={ignoreLabel}>
+              {ignoreLabel}
+            </ToggleButton.Button>
+            {canWarn && (
+              <ToggleButton.Button name="warn" label={warnLabel}>
+                {warnLabel}
+              </ToggleButton.Button>
+            )}
+            <ToggleButton.Button name="hide" label={hideLabel}>
+              {hideLabel}
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+        </View>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
new file mode 100644
index 000000000..0bfe69678
--- /dev/null
+++ b/src/components/moderation/PostAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationUI, ModerationCause} from '@atproto/api'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {getModerationCauseKey} from '#/lib/moderation'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function PostAlerts({
+  modui,
+  style,
+}: {
+  modui: ModerationUI
+  includeMute?: boolean
+  style?: StyleProp<ViewStyle>
+}) {
+  if (!modui.alert && !modui.inform) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_col, a.gap_xs, style]}>
+      <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
+        {modui.alerts.map(cause => (
+          <PostLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+        {modui.informs.map(cause => (
+          <PostLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+      </View>
+    </View>
+  )
+}
+
+function PostLabel({cause}: {cause: ModerationCause}) {
+  const control = useModerationDetailsDialogControl()
+  const desc = useModerationCauseDescription(cause)
+
+  return (
+    <>
+      <Button
+        label={desc.name}
+        variant="solid"
+        color="secondary"
+        size="small"
+        shape="default"
+        onPress={() => {
+          control.open()
+        }}
+        style={[a.px_sm, a.py_xs, a.gap_xs]}>
+        <ButtonIcon icon={desc.icon} position="left" />
+        <ButtonText style={[a.text_left, a.leading_snug]}>
+          {desc.name}
+        </ButtonText>
+      </Button>
+
+      <ModerationDetailsDialog control={control} modcause={cause} />
+    </>
+  )
+}
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
new file mode 100644
index 000000000..464ee2077
--- /dev/null
+++ b/src/components/moderation/PostHider.tsx
@@ -0,0 +1,129 @@
+import React, {ComponentProps} from 'react'
+import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
+import {ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+import {addStyle} from 'lib/styles'
+
+import {useTheme, atoms as a} from '#/alf'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+import {Text} from '#/components/Typography'
+// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
+import {Link} from '#/view/com/util/Link'
+
+interface Props extends ComponentProps<typeof Link> {
+  iconSize: number
+  iconStyles: StyleProp<ViewStyle>
+  modui: ModerationUI
+}
+
+export function PostHider({
+  testID,
+  href,
+  modui,
+  style,
+  children,
+  iconSize,
+  iconStyles,
+  ...props
+}: Props) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [override, setOverride] = React.useState(false)
+  const control = useModerationDetailsDialogControl()
+  const blur = modui.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur) {
+    return (
+      <Link
+        testID={testID}
+        style={style}
+        href={href}
+        accessible={false}
+        {...props}>
+        {children}
+      </Link>
+    )
+  }
+
+  return !override ? (
+    <Pressable
+      onPress={() => {
+        if (!modui.noOverride) {
+          setOverride(v => !v)
+        }
+      }}
+      accessibilityRole="button"
+      accessibilityHint={
+        override ? _(msg`Hide the content`) : _(msg`Show the content`)
+      }
+      accessibilityLabel=""
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.py_md,
+        {
+          paddingLeft: 6,
+          paddingRight: 18,
+        },
+        override ? {paddingBottom: 0} : undefined,
+        t.atoms.bg,
+      ]}>
+      <ModerationDetailsDialog control={control} modcause={blur} />
+      <Pressable
+        onPress={() => {
+          control.open()
+        }}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Learn more about this warning`)}
+        accessibilityHint="">
+        <View
+          style={[
+            t.atoms.bg_contrast_25,
+            a.align_center,
+            a.justify_center,
+            {
+              width: iconSize,
+              height: iconSize,
+              borderRadius: iconSize,
+            },
+            iconStyles,
+          ]}>
+          <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+        </View>
+      </Pressable>
+      <Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
+        {desc.name}
+      </Text>
+      {!modui.noOverride && (
+        <Text style={[{color: t.palette.primary_500}]}>
+          {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
+        </Text>
+      )}
+    </Pressable>
+  ) : (
+    <Link
+      testID={testID}
+      style={addStyle(style, styles.child)}
+      href={href}
+      accessible={false}
+      {...props}>
+      {children}
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  child: {
+    borderWidth: 0,
+    borderTopWidth: 0,
+    borderRadius: 8,
+  },
+})
diff --git a/src/components/moderation/ProfileHeaderAlerts.tsx b/src/components/moderation/ProfileHeaderAlerts.tsx
new file mode 100644
index 000000000..dfc2aa557
--- /dev/null
+++ b/src/components/moderation/ProfileHeaderAlerts.tsx
@@ -0,0 +1,66 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {ModerationCause, ModerationDecision} from '@atproto/api'
+
+import {getModerationCauseKey} from 'lib/moderation'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ProfileHeaderAlerts({
+  moderation,
+  style,
+}: {
+  moderation: ModerationDecision
+  style?: StyleProp<ViewStyle>
+}) {
+  const modui = moderation.ui('profileView')
+  if (!modui.alert && !modui.inform) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_col, a.gap_xs, style]}>
+      <View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
+        {modui.alerts.map(cause => (
+          <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+        {modui.informs.map(cause => (
+          <ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
+        ))}
+      </View>
+    </View>
+  )
+}
+
+function ProfileLabel({cause}: {cause: ModerationCause}) {
+  const control = useModerationDetailsDialogControl()
+  const desc = useModerationCauseDescription(cause)
+
+  return (
+    <>
+      <Button
+        label={desc.name}
+        variant="solid"
+        color="secondary"
+        size="small"
+        shape="default"
+        onPress={() => {
+          control.open()
+        }}
+        style={[a.px_sm, a.py_xs, a.gap_xs]}>
+        <ButtonIcon icon={desc.icon} position="left" />
+        <ButtonText style={[a.text_left, a.leading_snug]}>
+          {desc.name}
+        </ButtonText>
+      </Button>
+
+      <ModerationDetailsDialog control={control} modcause={cause} />
+    </>
+  )
+}
diff --git a/src/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx
new file mode 100644
index 000000000..71ca85a92
--- /dev/null
+++ b/src/components/moderation/ScreenHider.tsx
@@ -0,0 +1,171 @@
+import React from 'react'
+import {
+  TouchableWithoutFeedback,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
+import {ModerationUI} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
+
+import {useTheme, atoms as a} from '#/alf'
+import {CenteredView} from '#/view/com/util/Views'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {
+  ModerationDetailsDialog,
+  useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+export function ScreenHider({
+  testID,
+  screenDescription,
+  modui,
+  style,
+  containerStyle,
+  children,
+}: React.PropsWithChildren<{
+  testID?: string
+  screenDescription: string
+  modui: ModerationUI
+  style?: StyleProp<ViewStyle>
+  containerStyle?: StyleProp<ViewStyle>
+}>) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [override, setOverride] = React.useState(false)
+  const navigation = useNavigation<NavigationProp>()
+  const {isMobile} = useWebMediaQueries()
+  const control = useModerationDetailsDialogControl()
+  const blur = modui.blurs[0]
+  const desc = useModerationCauseDescription(blur)
+
+  if (!blur || override) {
+    return (
+      <View testID={testID} style={style}>
+        {children}
+      </View>
+    )
+  }
+
+  const isNoPwi = !!modui.blurs.find(
+    cause =>
+      cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
+  )
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        {
+          paddingTop: 100,
+          paddingBottom: 150,
+        },
+        t.atoms.bg,
+        containerStyle,
+      ]}
+      sideBorders>
+      <View style={[a.align_center, a.mb_md]}>
+        <View
+          style={[
+            t.atoms.bg_contrast_975,
+            a.align_center,
+            a.justify_center,
+            {
+              borderRadius: 25,
+              width: 50,
+              height: 50,
+            },
+          ]}>
+          <desc.icon width={24} fill={t.atoms.bg.backgroundColor} />
+        </View>
+      </View>
+      <Text
+        style={[
+          a.text_4xl,
+          a.font_semibold,
+          a.text_center,
+          a.mb_md,
+          t.atoms.text,
+        ]}>
+        {isNoPwi ? (
+          <Trans>Sign-in Required</Trans>
+        ) : (
+          <Trans>Content Warning</Trans>
+        )}
+      </Text>
+      <Text
+        style={[
+          a.text_lg,
+          a.mb_md,
+          a.px_lg,
+          a.text_center,
+          t.atoms.text_contrast_medium,
+        ]}>
+        {isNoPwi ? (
+          <Trans>
+            This account has requested that users sign in to view their profile.
+          </Trans>
+        ) : (
+          <>
+            <Trans>This {screenDescription} has been flagged:</Trans>
+            <Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
+              {desc.name}.{' '}
+            </Text>
+            <TouchableWithoutFeedback
+              onPress={() => {
+                control.open()
+              }}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Learn more about this warning`)}
+              accessibilityHint="">
+              <Text style={[a.text_lg, {color: t.palette.primary_500}]}>
+                <Trans>Learn More</Trans>
+              </Text>
+            </TouchableWithoutFeedback>
+
+            <ModerationDetailsDialog control={control} modcause={blur} />
+          </>
+        )}{' '}
+      </Text>
+      {isMobile && <View style={a.flex_1} />}
+      <View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}>
+        <Button
+          variant="solid"
+          color="primary"
+          size="large"
+          style={[a.rounded_full]}
+          label={_(msg`Go back`)}
+          onPress={() => {
+            if (navigation.canGoBack()) {
+              navigation.goBack()
+            } else {
+              navigation.navigate('Home')
+            }
+          }}>
+          <ButtonText>
+            <Trans>Go back</Trans>
+          </ButtonText>
+        </Button>
+        {!modui.noOverride && (
+          <Button
+            variant="solid"
+            color="secondary"
+            size="large"
+            style={[a.rounded_full]}
+            label={_(msg`Show anyway`)}
+            onPress={() => setOverride(v => !v)}>
+            <ButtonText>
+              <Trans>Show anyway</Trans>
+            </ButtonText>
+          </Button>
+        )}
+      </View>
+    </CenteredView>
+  )
+}
diff --git a/src/lib/__tests__/moderatePost_wrapped.test.ts b/src/lib/__tests__/moderatePost_wrapped.test.ts
deleted file mode 100644
index 45566281a..000000000
--- a/src/lib/__tests__/moderatePost_wrapped.test.ts
+++ /dev/null
@@ -1,692 +0,0 @@
-import {describe, it, expect} from '@jest/globals'
-import {RichText} from '@atproto/api'
-
-import {hasMutedWord} from '../moderatePost_wrapped'
-
-describe(`hasMutedWord`, () => {
-  describe(`tags`, () => {
-    it(`match: outline tag`, () => {
-      const rt = new RichText({
-        text: `This is a post #inlineTag`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'outlineTag', targets: ['tag']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: ['outlineTag'],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`match: inline tag`, () => {
-      const rt = new RichText({
-        text: `This is a post #inlineTag`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'inlineTag', targets: ['tag']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: ['outlineTag'],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`match: content target matches inline tag`, () => {
-      const rt = new RichText({
-        text: `This is a post #inlineTag`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'inlineTag', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: ['outlineTag'],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`no match: only tag targets`, () => {
-      const rt = new RichText({
-        text: `This is a post`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'inlineTag', targets: ['tag']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(false)
-    })
-  })
-
-  describe(`early exits`, () => {
-    it(`match: single character 希`, () => {
-      /**
-       * @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
-       */
-      const rt = new RichText({
-        text: `改善希望です`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: '希', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`no match: long muted word, short post`, () => {
-      const rt = new RichText({
-        text: `hey`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'politics', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(false)
-    })
-
-    it(`match: exact text`, () => {
-      const rt = new RichText({
-        text: `javascript`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'javascript', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-  })
-
-  describe(`general content`, () => {
-    it(`match: word within post`, () => {
-      const rt = new RichText({
-        text: `This is a post about javascript`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'javascript', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`no match: partial word`, () => {
-      const rt = new RichText({
-        text: `Use your brain, Eric`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'ai', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(false)
-    })
-
-    it(`match: multiline`, () => {
-      const rt = new RichText({
-        text: `Use your\n\tbrain, Eric`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'brain', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`match: :)`, () => {
-      const rt = new RichText({
-        text: `So happy :)`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      const match = hasMutedWord({
-        mutedWords: [{value: `:)`, targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-  })
-
-  describe(`punctuation semi-fuzzy`, () => {
-    describe(`yay!`, () => {
-      const rt = new RichText({
-        text: `We're federating, yay!`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: yay!`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'yay!', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: yay`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'yay', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`y!ppee!!`, () => {
-      const rt = new RichText({
-        text: `We're federating, y!ppee!!`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: y!ppee`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'y!ppee', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      // single exclamation point, source has double
-      it(`no match: y!ppee!`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'y!ppee!', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`Why so S@assy?`, () => {
-      const rt = new RichText({
-        text: `Why so S@assy?`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: S@assy`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'S@assy', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: s@assy`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 's@assy', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`New York Times`, () => {
-      const rt = new RichText({
-        text: `New York Times`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      // case insensitive
-      it(`match: new york times`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'new york times', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`!command`, () => {
-      const rt = new RichText({
-        text: `Idk maybe a bot !command`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: !command`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `!command`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: command`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `command`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`no match: !command`, () => {
-        const rt = new RichText({
-          text: `Idk maybe a bot command`,
-        })
-        rt.detectFacetsWithoutResolution()
-
-        const match = hasMutedWord({
-          mutedWords: [{value: `!command`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(false)
-      })
-    })
-
-    describe(`e/acc`, () => {
-      const rt = new RichText({
-        text: `I'm e/acc pilled`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: e/acc`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `e/acc`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: acc`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `acc`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`super-bad`, () => {
-      const rt = new RichText({
-        text: `I'm super-bad`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: super-bad`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `super-bad`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: super`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `super`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: super bad`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `super bad`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: superbad`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `superbad`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(false)
-      })
-    })
-
-    describe(`idk_what_this_would_be`, () => {
-      const rt = new RichText({
-        text: `Weird post with idk_what_this_would_be`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: idk what this would be`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `idk what this would be`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`no match: idk what this would be for`, () => {
-        // extra word
-        const match = hasMutedWord({
-          mutedWords: [
-            {value: `idk what this would be for`, targets: ['content']},
-          ],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(false)
-      })
-
-      it(`match: idk`, () => {
-        // extra word
-        const match = hasMutedWord({
-          mutedWords: [{value: `idk`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: idkwhatthiswouldbe`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(false)
-      })
-    })
-
-    describe(`parentheses`, () => {
-      const rt = new RichText({
-        text: `Post with context(iykyk)`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: context(iykyk)`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: context`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `context`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: iykyk`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `iykyk`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: (iykyk)`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `(iykyk)`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-
-    describe(`🦋`, () => {
-      const rt = new RichText({
-        text: `Post with 🦋`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: 🦋`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: `🦋`, targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-  })
-
-  describe(`phrases`, () => {
-    describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
-      const rt = new RichText({
-        text: `I like turtles, or how I learned to stop worrying and love the internet.`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      it(`match: stop worrying`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'stop worrying', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-
-      it(`match: turtles, or how`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'turtles, or how', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-  })
-
-  describe(`languages without spaces`, () => {
-    // I love turtles, or how I learned to stop worrying and love the internet
-    describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
-      const rt = new RichText({
-        text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
-      })
-      rt.detectFacetsWithoutResolution()
-
-      // internet
-      it(`match: インターネット`, () => {
-        const match = hasMutedWord({
-          mutedWords: [{value: 'インターネット', targets: ['content']}],
-          text: rt.text,
-          facets: rt.facets,
-          outlineTags: [],
-          languages: ['ja'],
-          isOwnPost: false,
-        })
-
-        expect(match).toBe(true)
-      })
-    })
-  })
-
-  describe(`doesn't mute own post`, () => {
-    it(`does mute if it isn't own post`, () => {
-      const rt = new RichText({
-        text: `Mute words!`,
-      })
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'words', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: false,
-      })
-
-      expect(match).toBe(true)
-    })
-
-    it(`doesn't mute own post when muted word is in text`, () => {
-      const rt = new RichText({
-        text: `Mute words!`,
-      })
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'words', targets: ['content']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: true,
-      })
-
-      expect(match).toBe(false)
-    })
-
-    it(`doesn't mute own post when muted word is in tags`, () => {
-      const rt = new RichText({
-        text: `Mute #words!`,
-      })
-
-      const match = hasMutedWord({
-        mutedWords: [{value: 'words', targets: ['tags']}],
-        text: rt.text,
-        facets: rt.facets,
-        outlineTags: [],
-        isOwnPost: true,
-      })
-
-      expect(match).toBe(false)
-    })
-  })
-})
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index e86844395..f5a72669a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
 
+export function IS_TEST_USER(handle?: string) {
+  return handle && handle?.endsWith('.test')
+}
+
 export function IS_PROD_SERVICE(url?: string) {
   return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
 }
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 9f6fa9c07..0ce01368a 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -1,380 +1,30 @@
-import {
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  moderatePost,
-  AppBskyActorDefs,
-  AppBskyFeedPost,
-  AppBskyRichtextFacet,
-  AppBskyEmbedImages,
-  AppBskyEmbedExternal,
-} from '@atproto/api'
+import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
-type Options = Parameters<ModeratePost>[1] & {
-  hiddenPosts?: string[]
-  mutedWords?: AppBskyActorDefs.MutedWord[]
-}
-
-const REGEX = {
-  LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
-  ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
-  SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
-  WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
-}
-
-/**
- * List of 2-letter lang codes for languages that either don't use spaces, or
- * don't use spaces in a way conducive to word-based filtering.
- *
- * For these, we use a simple `String.includes` to check for a match.
- */
-const LANGUAGE_EXCEPTIONS = [
-  'ja', // Japanese
-  'zh', // Chinese
-  'ko', // Korean
-  'th', // Thai
-  'vi', // Vietnamese
-]
-
-export function hasMutedWord({
-  mutedWords,
-  text,
-  facets,
-  outlineTags,
-  languages,
-  isOwnPost,
-}: {
-  mutedWords: AppBskyActorDefs.MutedWord[]
-  text: string
-  facets?: AppBskyRichtextFacet.Main[]
-  outlineTags?: string[]
-  languages?: string[]
-  isOwnPost: boolean
-}) {
-  if (isOwnPost) return false
-
-  const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
-  const tags = ([] as string[])
-    .concat(outlineTags || [])
-    .concat(
-      facets
-        ?.filter(facet => {
-          return facet.features.find(feature =>
-            AppBskyRichtextFacet.isTag(feature),
-          )
-        })
-        .map(t => t.features[0].tag as string) || [],
-    )
-    .map(t => t.toLowerCase())
-
-  for (const mute of mutedWords) {
-    const mutedWord = mute.value.toLowerCase()
-    const postText = text.toLowerCase()
-
-    // `content` applies to tags as well
-    if (tags.includes(mutedWord)) return true
-    // rest of the checks are for `content` only
-    if (!mute.targets.includes('content')) continue
-    // single character or other exception, has to use includes
-    if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
-      return true
-    // too long
-    if (mutedWord.length > postText.length) continue
-    // exact match
-    if (mutedWord === postText) return true
-    // any muted phrase with space or punctuation
-    if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
-      return true
-
-    // check individual character groups
-    const words = postText.split(REGEX.WORD_BOUNDARY)
-    for (const word of words) {
-      if (word === mutedWord) return true
-
-      // compare word without leading/trailing punctuation, but allow internal
-      // punctuation (such as `s@ssy`)
-      const wordTrimmedPunctuation = word.replace(
-        REGEX.LEADING_TRAILING_PUNCTUATION,
-        '',
-      )
-
-      if (mutedWord === wordTrimmedPunctuation) return true
-      if (mutedWord.length > wordTrimmedPunctuation.length) continue
-
-      // handle hyphenated, slash separated words, etc
-      if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
-        // check against full normalized phrase
-        const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
-          REGEX.SEPARATORS,
-          ' ',
-        )
-        const mutedWordNormalizedSeparators = mutedWord.replace(
-          REGEX.SEPARATORS,
-          ' ',
-        )
-        // hyphenated (or other sep) to spaced words
-        if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
-          return true
-
-        /* Disabled for now e.g. `super-cool` to `supercool`
-        const wordNormalizedCompressed = wordNormalizedSeparators.replace(
-          REGEX.WORD_BOUNDARY,
-          '',
-        )
-        const mutedWordNormalizedCompressed =
-          mutedWordNormalizedSeparators.replace(/\s+?/g, '')
-        // hyphenated (or other sep) to non-hyphenated contiguous word
-        if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
-          return true
-        */
-
-        // then individual parts of separated phrases/words
-        const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
-        for (const wp of wordParts) {
-          // still retain internal punctuation
-          if (wp === mutedWord) return true
-        }
-      }
-    }
-  }
-
-  return false
-}
+type Options = Parameters<ModeratePost>[1]
 
 export function moderatePost_wrapped(
   subject: Parameters<ModeratePost>[0],
   opts: Options,
 ) {
-  const {hiddenPosts = [], mutedWords = [], ...options} = opts
-  const moderations = moderatePost(subject, options)
-  const isOwnPost = subject.author.did === opts.userDid
-
-  if (hiddenPosts.includes(subject.uri)) {
-    moderations.content.filter = true
-    moderations.content.blur = true
-    if (!moderations.content.cause) {
-      moderations.content.cause = {
-        // @ts-ignore Temporary extension to the moderation system -prf
-        type: 'post-hidden',
-        source: {type: 'user'},
-        priority: 1,
-      }
-    }
-  }
+  // HACK
+  // temporarily translate 'gore' into 'graphic-media' during the transition period
+  // can remove this in a few months
+  // -prf
+  translateOldLabels(subject)
 
-  if (AppBskyFeedPost.isRecord(subject.record)) {
-    let muted = hasMutedWord({
-      mutedWords,
-      text: subject.record.text,
-      facets: subject.record.facets || [],
-      outlineTags: subject.record.tags || [],
-      languages: subject.record.langs,
-      isOwnPost,
-    })
-
-    if (
-      subject.record.embed &&
-      AppBskyEmbedImages.isMain(subject.record.embed)
-    ) {
-      for (const image of subject.record.embed.images) {
-        muted =
-          muted ||
-          hasMutedWord({
-            mutedWords,
-            text: image.alt,
-            facets: [],
-            outlineTags: [],
-            languages: subject.record.langs,
-            isOwnPost,
-          })
-      }
-    }
-
-    if (muted) {
-      moderations.content.filter = true
-      moderations.content.blur = true
-      if (!moderations.content.cause) {
-        moderations.content.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'muted-word',
-          source: {type: 'user'},
-          priority: 1,
-        }
-      }
-    }
-  }
-
-  if (subject.embed) {
-    let embedHidden = false
-    let embedMuted = false
-    let externalMuted = false
-
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.uri)
-    }
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
-    }
-
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
-        const embeddedPost = subject.embed.record.value
-
-        embedMuted =
-          embedMuted ||
-          hasMutedWord({
-            mutedWords,
-            text: embeddedPost.text,
-            facets: embeddedPost.facets,
-            outlineTags: embeddedPost.tags,
-            languages: embeddedPost.langs,
-            isOwnPost,
-          })
-
-        if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
-          for (const image of embeddedPost.embed.images) {
-            embedMuted =
-              embedMuted ||
-              hasMutedWord({
-                mutedWords,
-                text: image.alt,
-                facets: [],
-                outlineTags: [],
-                languages: embeddedPost.langs,
-                isOwnPost,
-              })
-          }
-        }
-
-        if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
-          const {external} = embeddedPost.embed
-
-          embedMuted =
-            embedMuted ||
-            hasMutedWord({
-              mutedWords,
-              text: external.title + ' ' + external.description,
-              facets: [],
-              outlineTags: [],
-              languages: [],
-              isOwnPost,
-            })
-        }
-
-        if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
-          if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
-            const {external} = embeddedPost.embed.media
-
-            embedMuted =
-              embedMuted ||
-              hasMutedWord({
-                mutedWords,
-                text: external.title + ' ' + external.description,
-                facets: [],
-                outlineTags: [],
-                languages: [],
-                isOwnPost,
-              })
-          }
-
-          if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
-            for (const image of embeddedPost.embed.media.images) {
-              embedMuted =
-                embedMuted ||
-                hasMutedWord({
-                  mutedWords,
-                  text: image.alt,
-                  facets: [],
-                  outlineTags: [],
-                  languages: AppBskyFeedPost.isRecord(embeddedPost.record)
-                    ? embeddedPost.langs
-                    : [],
-                  isOwnPost,
-                })
-            }
-          }
-        }
-      }
-    }
-
-    if (AppBskyEmbedExternal.isView(subject.embed)) {
-      const {external} = subject.embed
-
-      externalMuted =
-        externalMuted ||
-        hasMutedWord({
-          mutedWords,
-          text: external.title + ' ' + external.description,
-          facets: [],
-          outlineTags: [],
-          languages: [],
-          isOwnPost,
-        })
-    }
-
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
-        const post = subject.embed.record.record.value
-        embedMuted =
-          embedMuted ||
-          hasMutedWord({
-            mutedWords,
-            text: post.text,
-            facets: post.facets,
-            outlineTags: post.tags,
-            languages: post.langs,
-            isOwnPost,
-          })
-      }
-
-      if (AppBskyEmbedImages.isView(subject.embed.media)) {
-        for (const image of subject.embed.media.images) {
-          embedMuted =
-            embedMuted ||
-            hasMutedWord({
-              mutedWords,
-              text: image.alt,
-              facets: [],
-              outlineTags: [],
-              languages: AppBskyFeedPost.isRecord(subject.record)
-                ? subject.record.langs
-                : [],
-              isOwnPost,
-            })
-        }
-      }
-    }
+  return moderatePost(subject, opts)
+}
 
-    if (embedHidden) {
-      moderations.embed.filter = true
-      moderations.embed.blur = true
-      if (!moderations.embed.cause) {
-        moderations.embed.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'post-hidden',
-          source: {type: 'user'},
-          priority: 1,
-        }
-      }
-    } else if (externalMuted || embedMuted) {
-      moderations.content.filter = true
-      moderations.content.blur = true
-      if (!moderations.content.cause) {
-        moderations.content.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'muted-word',
-          source: {type: 'user'},
-          priority: 1,
-        }
+function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
+  if (subject.labels) {
+    for (const label of subject.labels) {
+      if (
+        label.val === 'gore' &&
+        (!label.src || label.src === BSKY_LABELER_DID)
+      ) {
+        label.val = 'graphic-media'
       }
     }
   }
-
-  return moderations
 }
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index b6ebb47a0..4105c2c2d 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,149 +1,81 @@
-import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
+import {
+  ModerationCause,
+  ModerationUI,
+  InterpretedLabelValueDefinition,
+  LABELS,
+  AppBskyLabelerDefs,
+  BskyAgent,
+  ModerationOpts,
+} from '@atproto/api'
 
-export interface ModerationCauseDescription {
-  name: string
-  description: string
-}
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 
-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') {
-    if (cause.source.type === 'list') {
-      return {
-        name: `User Blocked by "${cause.source.list.name}"`,
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    } else {
-      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',
-      }
-    }
-  }
-  // @ts-ignore Temporary extension to the moderation system -prf
-  if (cause.type === 'post-hidden') {
-    return {
-      name: 'Post Hidden by You',
-      description: 'You have hidden this post',
-    }
-  }
-  // @ts-ignore Temporary extension to the moderation system -prf
-  if (cause.type === 'muted-word') {
-    return {
-      name: 'Post hidden by muted word',
-      description: `You've chosen to hide a word or tag within this post.`,
-    }
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
   }
-  return cause.labelDef.strings[context].en
+  return `${cause.type}:${source}`
 }
 
-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 isJustAMute(modui: ModerationUI): boolean {
+  return modui.filters.length === 1 && modui.filters[0].type === 'muted'
 }
 
-export function isPostMediaBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return decisions.post.blurMedia
+export function getLabelingServiceTitle({
+  displayName,
+  handle,
+}: {
+  displayName?: string
+  handle: string
+}) {
+  return displayName
+    ? sanitizeDisplayName(displayName)
+    : sanitizeHandle(handle, '@')
 }
 
-export function isQuoteBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return (
-    decisions.quote?.blur ||
-    decisions.quote?.blurMedia ||
-    decisions.quote?.filter ||
-    decisions.quotedAccount?.blur ||
-    decisions.quotedAccount?.filter ||
-    false
-  )
+export function lookupLabelValueDefinition(
+  labelValue: string,
+  customDefs: InterpretedLabelValueDefinition[] | undefined,
+): InterpretedLabelValueDefinition | undefined {
+  let def
+  if (!labelValue.startsWith('!') && customDefs) {
+    def = customDefs.find(d => d.identifier === labelValue)
+  }
+  if (!def) {
+    def = LABELS[labelValue as keyof typeof LABELS]
+  }
+  return def
 }
 
-export function isCauseALabelOnUri(
-  cause: ModerationCause | undefined,
-  uri: string,
+export function isAppLabeler(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
 ): boolean {
-  if (cause?.type !== 'label') {
-    return false
+  if (typeof labeler === 'string') {
+    return BskyAgent.appLabelers.includes(labeler)
   }
-  return cause.label.uri === uri
+  return BskyAgent.appLabelers.includes(labeler.creator.did)
 }
 
-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}`
+export function isLabelerSubscribed(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
+  modOpts: ModerationOpts,
+) {
+  labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
+  if (isAppLabeler(labeler)) {
+    return true
   }
-  return `${cause.type}:${source}`
+  return modOpts.prefs.labelers.find(l => l.did === labeler)
 }
diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts
new file mode 100644
index 000000000..1c5a48231
--- /dev/null
+++ b/src/lib/moderation/useGlobalLabelStrings.ts
@@ -0,0 +1,52 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+
+export type GlobalLabelStrings = Record<
+  string,
+  {
+    name: string
+    description: string
+  }
+>
+
+export function useGlobalLabelStrings(): GlobalLabelStrings {
+  const {_} = useLingui()
+  return useMemo(
+    () => ({
+      '!hide': {
+        name: _(msg`Content Blocked`),
+        description: _(msg`This content has been hidden by the moderators.`),
+      },
+      '!warn': {
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`This content has received a general warning from moderators.`,
+        ),
+      },
+      '!no-unauthenticated': {
+        name: _(msg`Sign-in Required`),
+        description: _(
+          msg`This user has requested that their content only be shown to signed-in users.`,
+        ),
+      },
+      porn: {
+        name: _(msg`Pornography`),
+        description: _(msg`Explicit sexual images.`),
+      },
+      sexual: {
+        name: _(msg`Sexually Suggestive`),
+        description: _(msg`Does not include nudity.`),
+      },
+      nudity: {
+        name: _(msg`Non-sexual Nudity`),
+        description: _(msg`E.g. artistic nudes.`),
+      },
+      'graphic-media': {
+        name: _(msg`Graphic Media`),
+        description: _(msg`Explicit or potentially disturbing media.`),
+      },
+    }),
+    [_],
+  )
+}
diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts
new file mode 100644
index 000000000..0250c1bc8
--- /dev/null
+++ b/src/lib/moderation/useLabelBehaviorDescription.ts
@@ -0,0 +1,70 @@
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+export function useLabelBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Off`)
+  }
+  if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Disabled`)
+  }
+}
+
+export function useLabelLongBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Disabled`)
+  }
+  if (labelValueDef.blurs === 'content') {
+    if (pref === 'hide') {
+      return _(msg`Warn content and filter from feeds`)
+    }
+    return _(msg`Warn content`)
+  } else if (labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Blur images and filter from feeds`)
+    }
+    return _(msg`Blur images`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Show warning and filter from feeds`)
+    }
+    return _(msg`Show warning`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Show badge and filter from feeds`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Filter from feeds`)
+    }
+    return _(msg`Disabled`)
+  }
+}
diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts
new file mode 100644
index 000000000..b1cffe1e7
--- /dev/null
+++ b/src/lib/moderation/useLabelInfo.ts
@@ -0,0 +1,100 @@
+import {
+  ComAtprotoLabelDefs,
+  AppBskyLabelerDefs,
+  LABELS,
+  interpretLabelValueDefinition,
+  InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import * as bcp47Match from 'bcp-47-match'
+
+import {
+  GlobalLabelStrings,
+  useGlobalLabelStrings,
+} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelDefinitions} from '#/state/preferences'
+
+export interface LabelInfo {
+  label: ComAtprotoLabelDefs.Label
+  def: InterpretedLabelValueDefinition
+  strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+}
+
+export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
+  const {i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+  const def = getDefinition(labelDefs, label)
+  return {
+    label,
+    def,
+    strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
+    labeler: labelers.find(labeler => label.src === labeler.creator.did),
+  }
+}
+
+export function getDefinition(
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
+  label: ComAtprotoLabelDefs.Label,
+): InterpretedLabelValueDefinition {
+  // check local definitions
+  const customDef =
+    !label.val.startsWith('!') &&
+    labelDefs[label.src]?.find(
+      def => def.identifier === label.val && def.definedBy === label.src,
+    )
+  if (customDef) {
+    return customDef
+  }
+
+  // check global definitions
+  const globalDef = LABELS[label.val as keyof typeof LABELS]
+  if (globalDef) {
+    return globalDef
+  }
+
+  // fallback to a noop definition
+  return interpretLabelValueDefinition(
+    {
+      identifier: label.val,
+      severity: 'none',
+      blurs: 'none',
+      defaultSetting: 'ignore',
+      locales: [],
+    },
+    label.src,
+  )
+}
+
+export function getLabelStrings(
+  locale: string,
+  globalLabelStrings: GlobalLabelStrings,
+  def: InterpretedLabelValueDefinition,
+): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
+  if (!def.definedBy) {
+    // global definition, look up strings
+    if (def.identifier in globalLabelStrings) {
+      return globalLabelStrings[
+        def.identifier
+      ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
+    }
+  } else {
+    // try to find locale match in the definition's strings
+    const localeMatch = def.locales.find(
+      strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
+    )
+    if (localeMatch) {
+      return localeMatch
+    }
+    // fall back to the zero item if no match
+    if (def.locales[0]) {
+      return def.locales[0]
+    }
+  }
+  return {
+    lang: locale,
+    name: def.identifier,
+    description: `Labeled "${def.identifier}"`,
+  }
+}
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
new file mode 100644
index 000000000..46771e958
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,146 @@
+import React from 'react'
+import {
+  BSKY_LABELER_DID,
+  ModerationCause,
+  ModerationCauseSource,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {getDefinition, getLabelStrings} from './useLabelInfo'
+import {useLabelDefinitions} from '#/state/preferences'
+import {useGlobalLabelStrings} from './useGlobalLabelStrings'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+
+export interface ModerationCauseDescription {
+  icon: React.ComponentType<SVGIconProps>
+  name: string
+  description: string
+  source?: string
+  sourceType?: ModerationCauseSource['type']
+}
+
+export function useModerationCauseDescription(
+  cause: ModerationCause | undefined,
+): ModerationCauseDescription {
+  const {_, i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+
+  return React.useMemo(() => {
+    if (!cause) {
+      return {
+        icon: Warning,
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`Moderator has chosen to set a general warning on the content.`,
+        ),
+      }
+    }
+    if (cause.type === 'blocking') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked by "${cause.source.list.name}"`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      } else {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      }
+    }
+    if (cause.type === 'blocked-by') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`User Blocking You`),
+        description: _(
+          msg`This user has blocked you. You cannot view their content.`,
+        ),
+      }
+    }
+    if (cause.type === 'block-other') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`Content Not Available`),
+        description: _(
+          msg`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 {
+          icon: EyeSlash,
+          name: _(msg`Muted by "${cause.source.list.name}"`),
+          description: _(msg`You have muted this user`),
+        }
+      } else {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Account Muted`),
+          description: _(msg`You have muted this account.`),
+        }
+      }
+    }
+    if (cause.type === 'mute-word') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by Muted Word`),
+        description: _(
+          msg`You've chosen to hide a word or tag within this post.`,
+        ),
+      }
+    }
+    if (cause.type === 'hidden') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by You`),
+        description: _(msg`You have hidden this post`),
+      }
+    }
+    if (cause.type === 'label') {
+      const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+      const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
+      const labeler = labelers.find(l => l.creator.did === cause.label.src)
+      let source =
+        labeler?.creator.displayName ||
+        (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
+      if (!source) {
+        if (cause.label.src === BSKY_LABELER_DID) {
+          source = 'Bluesky Moderation'
+        } else {
+          source = cause.label.src
+        }
+      }
+      return {
+        icon:
+          def.identifier === '!no-unauthenticated'
+            ? EyeSlash
+            : def.severity === 'alert'
+            ? Warning
+            : CircleInfo,
+        name: strings.name,
+        description: strings.description,
+        source,
+        sourceType: cause.source.type,
+      }
+    }
+    // should never happen
+    return {
+      icon: CircleInfo,
+      name: '',
+      description: ``,
+    }
+  }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
new file mode 100644
index 000000000..e00170594
--- /dev/null
+++ b/src/lib/moderation/useReportOptions.ts
@@ -0,0 +1,94 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+
+export interface ReportOption {
+  reason: string
+  title: string
+  description: string
+}
+
+interface ReportOptions {
+  account: ReportOption[]
+  post: ReportOption[]
+  list: ReportOption[]
+  feedgen: ReportOption[]
+  other: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+  const {_} = useLingui()
+  return useMemo(() => {
+    const other = {
+      reason: ComAtprotoModerationDefs.REASONOTHER,
+      title: _(msg`Other`),
+      description: _(msg`An issue not included in these options`),
+    }
+    const common = [
+      {
+        reason: ComAtprotoModerationDefs.REASONRUDE,
+        title: _(msg`Anti-Social Behavior`),
+        description: _(msg`Harassment, trolling, or intolerance`),
+      },
+      {
+        reason: ComAtprotoModerationDefs.REASONVIOLATION,
+        title: _(msg`Illegal and Urgent`),
+        description: _(msg`Glaring violations of law or terms of service`),
+      },
+      other,
+    ]
+    return {
+      account: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Account`),
+          description: _(
+            msg`Impersonation or false claims about identity or affiliation`,
+          ),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Frequently Posts Unwanted Content`),
+          description: _(msg`Spam; excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        other,
+      ],
+      post: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Nudity or pornography not labeled as such`),
+        },
+        ...common,
+      ],
+      list: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      feedgen: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      other: common,
+    }
+  }, [_])
+}
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
index 7fe3fe7a4..d6cd3c54b 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.ts
@@ -1,7 +1,14 @@
 import {AppState, AppStateStatus} from 'react-native'
 import {QueryClient, focusManager} from '@tanstack/react-query'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
+
 import {isNative} from '#/platform/detection'
 
+// any query keys in this array will be persisted to AsyncStorage
+const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
+
 focusManager.setEventListener(onFocus => {
   if (isNative) {
     const subscription = AppState.addEventListener(
@@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
     },
   },
 })
+
+export const asyncStoragePersister = createAsyncStoragePersister({
+  storage: AsyncStorage,
+  key: 'queryCache',
+})
+
+export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
+  {
+    shouldDehydrateMutation: (_: any) => false,
+    shouldDehydrateQuery: query => {
+      return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
+    },
+  }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 6756a62a6..95af2f237 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
   PostRepostedBy: {name: string; rkey: string}
   ProfileFeed: {name: string; rkey: string}
   ProfileFeedLikedBy: {name: string; rkey: string}
+  ProfileLabelerLikedBy: {name: string}
   Debug: undefined
+  DebugMod: undefined
   Log: undefined
   Support: undefined
   PrivacyPolicy: undefined
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4f..e0f23fa2c 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
 import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
 
 // \u2705 = ✅
 // \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
   moderation?: ModerationUI,
 ): string {
   if (moderation?.blur) {
-    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+    return ''
   }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index bd75aabea..6fada40a7 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
   palette: {
     default: {
       background: lightPalette.white,
-      backgroundLight: lightPalette.contrast_50,
+      backgroundLight: lightPalette.contrast_25,
       text: lightPalette.black,
       textLight: lightPalette.contrast_700,
       textInverted: lightPalette.white,
diff --git a/src/routes.ts b/src/routes.ts
index 5c263fd6f..f6f372947 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -21,7 +21,9 @@ export const router = new Router({
   PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
   ProfileFeed: '/profile/:name/feed/:rkey',
   ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
+  ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
   Debug: '/sys/debug',
+  DebugMod: '/sys/debug-mod',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
   PreferencesFollowingFeed: '/settings/following-feed',
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
new file mode 100644
index 000000000..26fa9ec77
--- /dev/null
+++ b/src/screens/Moderation/index.tsx
@@ -0,0 +1,560 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {LABELS} from '@atproto/api'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {CenteredView} from '#/view/com/util/Views'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useSession} from '#/state/session'
+import {
+  useProfileQuery,
+  useProfileUpdateMutation,
+} from '#/state/queries/profile'
+import {ScrollView} from '#/view/com/util/Views'
+
+import {
+  UsePreferencesQueryResponse,
+  useMyLabelersQuery,
+  usePreferencesQuery,
+  usePreferencesSetAdultContentMutation,
+} from '#/state/queries/preferences'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {logger} from '#/logger'
+import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
+import {Divider} from '#/components/Divider'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {Text} from '#/components/Typography'
+import * as Toggle from '#/components/forms/Toggle'
+import {InlineLink, Link} from '#/components/Link'
+import {Button, ButtonText} from '#/components/Button'
+import {Loader} from '#/components/Loader'
+import * as LabelingService from '#/components/LabelingServiceCard'
+import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import * as Dialog from '#/components/Dialog'
+
+function ErrorState({error}: {error: string}) {
+  const t = useTheme()
+  return (
+    <View style={[a.p_xl]}>
+      <Text
+        style={[
+          a.text_md,
+          a.leading_normal,
+          a.pb_md,
+          t.atoms.text_contrast_medium,
+        ]}>
+        <Trans>
+          Hmmmm, it seems we're having trouble loading this data. See below for
+          more details. If this issue persists, please contact us.
+        </Trans>
+      </Text>
+      <View
+        style={[
+          a.relative,
+          a.py_md,
+          a.px_lg,
+          a.rounded_md,
+          a.mb_2xl,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text style={[a.text_md, a.leading_normal]}>{error}</Text>
+      </View>
+    </View>
+  )
+}
+
+export function ModerationScreen(
+  _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
+) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {
+    isLoading: isPreferencesLoading,
+    error: preferencesError,
+    data: preferences,
+  } = usePreferencesQuery()
+  const {gtMobile} = useBreakpoints()
+  const {height} = useSafeAreaFrame()
+
+  const isLoading = isPreferencesLoading
+  const error = preferencesError
+
+  return (
+    <CenteredView
+      testID="moderationScreen"
+      style={[
+        t.atoms.border_contrast_low,
+        t.atoms.bg,
+        {minHeight: height},
+        ...(gtMobile ? [a.border_l, a.border_r] : []),
+      ]}>
+      <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
+
+      {isLoading ? (
+        <View style={[a.w_full, a.align_center, a.pt_2xl]}>
+          <Loader size="xl" fill={t.atoms.text.color} />
+        </View>
+      ) : error || !preferences ? (
+        <ErrorState
+          error={
+            preferencesError?.toString() ||
+            _(msg`Something went wrong, please try again.`)
+          }
+        />
+      ) : (
+        <ModerationScreenInner preferences={preferences} />
+      )}
+    </CenteredView>
+  )
+}
+
+function SubItem({
+  title,
+  icon: Icon,
+  style,
+}: ViewStyleProp & {
+  title: string
+  icon: React.ComponentType<SVGIconProps>
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.p_lg,
+        a.gap_sm,
+        style,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.gap_md]}>
+        <Icon size="md" style={[t.atoms.text_contrast_medium]} />
+        <Text style={[a.text_sm, a.font_bold]}>{title}</Text>
+      </View>
+      <ChevronRight
+        size="sm"
+        style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
+      />
+    </View>
+  )
+}
+
+export function ModerationScreenInner({
+  preferences,
+}: {
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {gtMobile} = useBreakpoints()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+  const birthdateDialogControl = Dialog.useDialogControl()
+  const {
+    isLoading: isLabelersLoading,
+    data: labelers,
+    error: labelersError,
+  } = useMyLabelersQuery()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Moderation')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
+    usePreferencesSetAdultContentMutation()
+  const adultContentEnabled = !!(
+    (optimisticAdultContent && optimisticAdultContent.enabled) ||
+    (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
+  )
+  const ageNotSet = !preferences.userAge
+  const isUnderage = (preferences.userAge || 0) < 18
+
+  const onToggleAdultContentEnabled = React.useCallback(
+    async (selected: boolean) => {
+      try {
+        await setAdultContentPref({
+          enabled: selected,
+        })
+      } catch (e: any) {
+        logger.error(`Failed to set adult content pref`, {
+          message: e.message,
+        })
+      }
+    },
+    [setAdultContentPref],
+  )
+
+  return (
+    <View>
+      <ScrollView
+        contentContainerStyle={[
+          a.border_0,
+          a.pt_2xl,
+          a.px_lg,
+          gtMobile && a.px_2xl,
+        ]}>
+        <Text
+          style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
+          <Trans>Moderation tools</Trans>
+        </Text>
+
+        <View
+          style={[
+            a.w_full,
+            a.rounded_md,
+            a.overflow_hidden,
+            t.atoms.bg_contrast_25,
+          ]}>
+          <Button
+            testID="mutedWordsBtn"
+            label={_(msg`Open muted words and tags settings`)}
+            onPress={() => mutedWordsDialogControl.open()}>
+            {state => (
+              <SubItem
+                title={_(msg`Muted words & tags`)}
+                icon={Filter}
+                style={[
+                  (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+                ]}
+              />
+            )}
+          </Button>
+          <Divider />
+          <Link testID="moderationlistsBtn" to="/moderation/modlists">
+            {state => (
+              <SubItem
+                title={_(msg`Moderation lists`)}
+                icon={Group}
+                style={[
+                  (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+                ]}
+              />
+            )}
+          </Link>
+          <Divider />
+          <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
+            {state => (
+              <SubItem
+                title={_(msg`Muted accounts`)}
+                icon={Person}
+                style={[
+                  (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+                ]}
+              />
+            )}
+          </Link>
+          <Divider />
+          <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
+            {state => (
+              <SubItem
+                title={_(msg`Blocked accounts`)}
+                icon={CircleBanSign}
+                style={[
+                  (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+                ]}
+              />
+            )}
+          </Link>
+        </View>
+
+        <Text
+          style={[
+            a.pt_2xl,
+            a.pb_md,
+            a.text_md,
+            a.font_bold,
+            t.atoms.text_contrast_high,
+          ]}>
+          <Trans>Content filters</Trans>
+        </Text>
+
+        <View style={[a.gap_md]}>
+          {ageNotSet && (
+            <>
+              <Button
+                label={_(msg`Confirm your birthdate`)}
+                size="small"
+                variant="solid"
+                color="secondary"
+                onPress={() => {
+                  birthdateDialogControl.open()
+                }}
+                style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
+                <ButtonText>
+                  <Trans>Confirm your age:</Trans>
+                </ButtonText>
+                <ButtonText>
+                  <Trans>Set birthdate</Trans>
+                </ButtonText>
+              </Button>
+
+              <BirthDateSettingsDialog
+                control={birthdateDialogControl}
+                preferences={preferences}
+              />
+            </>
+          )}
+          <View
+            style={[
+              a.w_full,
+              a.rounded_md,
+              a.overflow_hidden,
+              t.atoms.bg_contrast_25,
+            ]}>
+            {!ageNotSet && !isUnderage && (
+              <>
+                <View
+                  style={[
+                    a.py_lg,
+                    a.px_lg,
+                    a.flex_row,
+                    a.align_center,
+                    a.justify_between,
+                  ]}>
+                  <Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
+                    <Trans>Enable adult content</Trans>
+                  </Text>
+                  <Toggle.Item
+                    label={_(msg`Toggle to enable or disable adult content`)}
+                    name="adultContent"
+                    value={adultContentEnabled}
+                    onChange={onToggleAdultContentEnabled}>
+                    <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                      <Text style={[t.atoms.text_contrast_medium]}>
+                        {adultContentEnabled ? (
+                          <Trans>Enabled</Trans>
+                        ) : (
+                          <Trans>Disabled</Trans>
+                        )}
+                      </Text>
+                      <Toggle.Switch />
+                    </View>
+                  </Toggle.Item>
+                </View>
+                <Divider />
+              </>
+            )}
+            {!isUnderage && adultContentEnabled && (
+              <>
+                <GlobalModerationLabelPref labelValueDefinition={LABELS.porn} />
+                <Divider />
+                <GlobalModerationLabelPref
+                  labelValueDefinition={LABELS.sexual}
+                />
+                <Divider />
+                <GlobalModerationLabelPref
+                  labelValueDefinition={LABELS['graphic-media']}
+                />
+                <Divider />
+              </>
+            )}
+            <GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} />
+          </View>
+        </View>
+
+        <Text
+          style={[
+            a.text_md,
+            a.font_bold,
+            a.pt_2xl,
+            a.pb_md,
+            t.atoms.text_contrast_high,
+          ]}>
+          <Trans>Advanced</Trans>
+        </Text>
+
+        {isLabelersLoading ? (
+          <Loader />
+        ) : labelersError || !labelers ? (
+          <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
+            <Text>
+              <Trans>
+                We were unable to load your configured labelers at this time.
+              </Trans>
+            </Text>
+          </View>
+        ) : (
+          <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
+            {labelers.map((labeler, i) => {
+              return (
+                <React.Fragment key={labeler.creator.did}>
+                  {i !== 0 && <Divider />}
+                  <LabelingService.Link labeler={labeler}>
+                    {state => (
+                      <LabelingService.Outer
+                        style={[
+                          i === 0 && {
+                            borderTopLeftRadius: a.rounded_sm.borderRadius,
+                            borderTopRightRadius: a.rounded_sm.borderRadius,
+                          },
+                          i === labelers.length - 1 && {
+                            borderBottomLeftRadius: a.rounded_sm.borderRadius,
+                            borderBottomRightRadius: a.rounded_sm.borderRadius,
+                          },
+                          (state.hovered || state.pressed) && [
+                            t.atoms.bg_contrast_50,
+                          ],
+                        ]}>
+                        <LabelingService.Avatar />
+                        <LabelingService.Content>
+                          <LabelingService.Title
+                            value={getLabelingServiceTitle({
+                              displayName: labeler.creator.displayName,
+                              handle: labeler.creator.handle,
+                            })}
+                          />
+                          <LabelingService.Description
+                            value={labeler.creator.description}
+                            handle={labeler.creator.handle}
+                          />
+                        </LabelingService.Content>
+                      </LabelingService.Outer>
+                    )}
+                  </LabelingService.Link>
+                </React.Fragment>
+              )
+            })}
+          </View>
+        )}
+
+        <Text
+          style={[
+            a.text_md,
+            a.font_bold,
+            a.pt_2xl,
+            a.pb_md,
+            t.atoms.text_contrast_high,
+          ]}>
+          <Trans>Logged-out visibility</Trans>
+        </Text>
+
+        <PwiOptOut />
+
+        <View style={{height: 200}} />
+      </ScrollView>
+    </View>
+  )
+}
+
+function PwiOptOut() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
+  const updateProfile = useProfileUpdateMutation()
+
+  const isOptedOut =
+    profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
+  const canToggle = profile && !updateProfile.isPending
+
+  const onToggleOptOut = React.useCallback(() => {
+    if (!profile) {
+      return
+    }
+    let wasAdded = false
+    updateProfile.mutate({
+      profile,
+      updates: existing => {
+        // create labels attr if needed
+        existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
+          ? existing.labels
+          : {
+              $type: 'com.atproto.label.defs#selfLabels',
+              values: [],
+            }
+
+        // toggle the label
+        const hasLabel = existing.labels.values.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        if (hasLabel) {
+          wasAdded = false
+          existing.labels.values = existing.labels.values.filter(
+            l => l.val !== '!no-unauthenticated',
+          )
+        } else {
+          wasAdded = true
+          existing.labels.values.push({val: '!no-unauthenticated'})
+        }
+
+        // delete if no longer needed
+        if (existing.labels.values.length === 0) {
+          delete existing.labels
+        }
+        return existing
+      },
+      checkCommitted: res => {
+        const exists = !!res.data.labels?.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        return exists === wasAdded
+      },
+    })
+  }, [updateProfile, profile])
+
+  return (
+    <View style={[a.pt_sm]}>
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <Toggle.Item
+          disabled={!canToggle}
+          value={isOptedOut}
+          onChange={onToggleOptOut}
+          name="logged_out_visibility"
+          label={_(
+            msg`Discourage apps from showing my account to logged-out users`,
+          )}>
+          <Toggle.Switch />
+          <Toggle.Label style={[a.text_md]}>
+            <Trans>
+              Discourage apps from showing my account to logged-out users
+            </Trans>
+          </Toggle.Label>
+        </Toggle.Item>
+
+        {updateProfile.isPending && <Loader />}
+      </View>
+
+      <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
+        <Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>
+            Bluesky will not show your profile and posts to logged-out users.
+            Other apps may not honor this request. This does not make your
+            account private.
+          </Trans>
+        </Text>
+        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>
+            Note: Bluesky is an open and public network. This setting only
+            limits the visibility of your content on the Bluesky app and
+            website, and other apps may not respect this setting. Your content
+            may still be shown to logged-out users by other apps and websites.
+          </Trans>
+        </Text>
+
+        <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
+          <Trans>Learn more about what is public on Bluesky.</Trans>
+        </InlineLink>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
index 360025c02..0bff53436 100644
--- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
+++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
@@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
 
     try {
       mutate({
-        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
+        enabled: !(
+          variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
+        ),
       })
     } catch (e) {
       Toast.show(
@@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
           <Toggle.Item
             name={_(msg`Enable adult content in your feeds`)}
             label={_(msg`Enable adult content in your feeds`)}
-            value={variables?.enabled ?? preferences?.adultContentEnabled}
+            value={
+              variables?.enabled ??
+              preferences?.moderationPrefs.adultContentEnabled
+            }
             onChange={onToggleAdultContent}>
             <View
               style={[
diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
index c61b520ba..ac02a874c 100644
--- a/src/screens/Onboarding/StepModeration/ModerationOption.tsx
+++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
@@ -1,40 +1,51 @@
 import React from 'react'
 import {View} from 'react-native'
-import {LabelPreference} from '@atproto/api'
+import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
 
 import {
-  CONFIGURABLE_LABEL_GROUPS,
-  ConfigurableLabelGroup,
   usePreferencesQuery,
   usePreferencesSetContentLabelMutation,
 } from '#/state/queries/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 import * as ToggleButton from '#/components/forms/ToggleButton'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
 
 export function ModerationOption({
-  labelGroup,
-  isMounted,
+  labelValueDefinition,
+  disabled,
 }: {
-  labelGroup: ConfigurableLabelGroup
-  isMounted: React.MutableRefObject<boolean>
+  labelValueDefinition: InterpretedLabelValueDefinition
+  disabled?: boolean
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
   const {data: preferences} = usePreferencesQuery()
   const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const label = labelValueDefinition.identifier
   const visibility =
-    variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
+    variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
+
+  const allLabelStrings = useGlobalLabelStrings()
+  const labelStrings =
+    labelValueDefinition.identifier in allLabelStrings
+      ? allLabelStrings[labelValueDefinition.identifier]
+      : {
+          name: labelValueDefinition.identifier,
+          description: `Labeled "${labelValueDefinition.identifier}"`,
+        }
 
   const onChange = React.useCallback(
     (vis: string[]) => {
-      mutate({labelGroup, visibility: vis[0] as LabelPreference})
+      mutate({
+        label,
+        visibility: vis[0] as LabelPreference,
+        labelerDid: undefined,
+      })
     },
-    [mutate, labelGroup],
+    [mutate, label],
   )
 
   const labels = {
@@ -44,7 +55,7 @@ export function ModerationOption({
   }
 
   return (
-    <Animated.View
+    <View
       style={[
         a.flex_row,
         a.justify_between,
@@ -52,33 +63,37 @@ export function ModerationOption({
         a.py_xs,
         a.px_xs,
         a.align_center,
-      ]}
-      layout={Layout.easing(Easing.ease).duration(200)}
-      entering={isMounted.current ? FadeIn : undefined}>
-      <View style={[a.gap_xs, {width: '50%'}]}>
-        <Text style={[a.font_bold]}>{groupInfo.title}</Text>
+      ]}>
+      <View style={[a.gap_xs, a.flex_1]}>
+        <Text style={[a.font_bold]}>{labelStrings.name}</Text>
         <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
-          {groupInfo.subtitle}
+          {labelStrings.description}
         </Text>
       </View>
-      <View style={[a.justify_center, {minHeight: 35}]}>
-        <ToggleButton.Group
-          label={_(
-            msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
-          )}
-          values={[visibility ?? 'hide']}
-          onChange={onChange}>
-          <ToggleButton.Button name="hide" label={labels.hide}>
-            {labels.hide}
-          </ToggleButton.Button>
-          <ToggleButton.Button name="warn" label={labels.warn}>
-            {labels.warn}
-          </ToggleButton.Button>
-          <ToggleButton.Button name="ignore" label={labels.show}>
-            {labels.show}
-          </ToggleButton.Button>
-        </ToggleButton.Group>
+      <View style={[a.justify_center, {minHeight: 40}]}>
+        {disabled ? (
+          <Text style={[a.font_bold]}>
+            <Trans>Hide</Trans>
+          </Text>
+        ) : (
+          <ToggleButton.Group
+            label={_(
+              msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
+            )}
+            values={[visibility ?? 'hide']}
+            onChange={onChange}>
+            <ToggleButton.Button name="ignore" label={labels.show}>
+              {labels.show}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="warn" label={labels.warn}>
+              {labels.warn}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="hide" label={labels.hide}>
+              {labels.hide}
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+        )}
       </View>
-    </Animated.View>
+    </View>
   )
 }
diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx
index 543a5b159..9b52f9f43 100644
--- a/src/screens/Onboarding/StepModeration/index.tsx
+++ b/src/screens/Onboarding/StepModeration/index.tsx
@@ -2,15 +2,10 @@ import React from 'react'
 import {View} from 'react-native'
 import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
-import Animated, {Easing, Layout} from 'react-native-reanimated'
+import {LABELS} from '@atproto/api'
 
 import {atoms as a} from '#/alf'
-import {
-  configurableAdultLabelGroups,
-  configurableOtherLabelGroups,
-  usePreferencesSetAdultContentMutation,
-} from 'state/queries/preferences'
-import {Divider} from '#/components/Divider'
+import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
@@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult
 import {Context} from '#/screens/Onboarding/state'
 import {IconCircle} from '#/components/IconCircle'
 
-function AnimatedDivider() {
-  return (
-    <Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
-      <Divider />
-    </Animated.View>
-  )
-}
-
 export function StepModeration() {
   const {_} = useLingui()
   const {track} = useAnalytics()
@@ -52,7 +39,7 @@ export function StepModeration() {
 
   const adultContentEnabled = !!(
     (variables && variables.enabled) ||
-    (!variables && preferences?.adultContentEnabled)
+    (!variables && preferences?.moderationPrefs.adultContentEnabled)
   )
 
   const onContinue = React.useCallback(() => {
@@ -86,22 +73,19 @@ export function StepModeration() {
           <AdultContentEnabledPref mutate={mutate} variables={variables} />
 
           <View style={[a.gap_sm, a.w_full]}>
-            {adultContentEnabled &&
-              configurableAdultLabelGroups.map((g, index) => (
-                <React.Fragment key={index}>
-                  {index === 0 && <AnimatedDivider />}
-                  <ModerationOption labelGroup={g} isMounted={isMounted} />
-                  <AnimatedDivider />
-                </React.Fragment>
-              ))}
-
-            {configurableOtherLabelGroups.map((g, index) => (
-              <React.Fragment key={index}>
-                {!adultContentEnabled && index === 0 && <AnimatedDivider />}
-                <ModerationOption labelGroup={g} isMounted={isMounted} />
-                <AnimatedDivider />
-              </React.Fragment>
-            ))}
+            <ModerationOption
+              labelValueDefinition={LABELS.porn}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption
+              labelValueDefinition={LABELS.sexual}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption
+              labelValueDefinition={LABELS['graphic-media']}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption labelValueDefinition={LABELS.nudity} />
           </View>
         </>
       )}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
index 067005892..7e4ea1f8b 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -88,7 +88,7 @@ export function SuggestedAccountCard({
             <UserAvatar
               size={48}
               avatar={profile.avatar}
-              moderation={moderation.avatar}
+              moderation={moderation.ui('avatar')}
             />
           </View>
           <View style={[a.flex_1]}>
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
index 14faddc10..bdf94d824 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
       state.interestsStepResults.apiResponse.suggestedAccountDids,
-      state.interestsStepResults.apiResponse.suggestedAccountDids.default,
+      state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
     )
   }, [state.interestsStepResults])
   const moderationOpts = useModerationOpts()
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
index 636565e34..089363c23 100644
--- a/src/screens/Onboarding/StepTopicalFeeds.tsx
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -21,7 +21,7 @@ import {
 import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
 import {aggregateInterestItems} from '#/screens/Onboarding/util'
 import {IconCircle} from '#/components/IconCircle'
-import {IS_PROD_SERVICE} from 'lib/constants'
+import {IS_TEST_USER} from 'lib/constants'
 import {useSession} from 'state/session'
 
 export function StepTopicalFeeds() {
@@ -32,14 +32,14 @@ export function StepTopicalFeeds() {
   const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
   const [saving, setSaving] = React.useState(false)
   const suggestedFeedUris = React.useMemo(() => {
-    if (!IS_PROD_SERVICE(currentAccount?.service)) return []
+    if (IS_TEST_USER(currentAccount?.handle)) return []
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
       state.interestsStepResults.apiResponse.suggestedFeedUris,
-      state.interestsStepResults.apiResponse.suggestedFeedUris.default,
+      state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
     ).slice(0, 10)
   }, [
-    currentAccount?.service,
+    currentAccount?.handle,
     state.interestsStepResults.apiResponse.suggestedFeedUris,
     state.interestsStepResults.selectedInterests,
   ])
diff --git a/src/screens/Profile/ErrorState.tsx b/src/screens/Profile/ErrorState.tsx
new file mode 100644
index 000000000..2ec2cf592
--- /dev/null
+++ b/src/screens/Profile/ErrorState.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {NavigationProp} from '#/lib/routes/types'
+
+export function ErrorState({error}: {error: string}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  return (
+    <View style={[a.px_xl]}>
+      <CircleInfo width={48} style={[t.atoms.text_contrast_low]} />
+
+      <Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}>
+        <Trans>Hmmmm, we couldn't load that moderation service.</Trans>
+      </Text>
+      <Text
+        style={[
+          a.text_md,
+          a.leading_normal,
+          a.pb_md,
+          t.atoms.text_contrast_medium,
+        ]}>
+        <Trans>
+          This moderation service is unavailable. See below for more details. If
+          this issue persists, contact us.
+        </Trans>
+      </Text>
+      <View
+        style={[
+          a.relative,
+          a.py_md,
+          a.px_lg,
+          a.rounded_md,
+          a.mb_2xl,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text style={[a.text_md, a.leading_normal]}>{error}</Text>
+      </View>
+
+      <View style={{flexDirection: 'row'}}>
+        <Button
+          size="small"
+          color="secondary"
+          variant="solid"
+          label={_(msg`Go Back`)}
+          accessibilityHint="Return to previous page"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
new file mode 100644
index 000000000..b6d88db71
--- /dev/null
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {Shadow} from '#/state/cache/types'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderDisplayName({
+  profile,
+  moderation,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  moderation: ModerationDecision
+}) {
+  const t = useTheme()
+  return (
+    <View pointerEvents="none">
+      <Text
+        testID="profileHeaderDisplayName"
+        style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}>
+        {sanitizeDisplayName(
+          profile.displayName || sanitizeHandle(profile.handle),
+          moderation.ui('displayName'),
+        )}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
new file mode 100644
index 000000000..fd1cbe533
--- /dev/null
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {isInvalidHandle} from 'lib/strings/handles'
+import {Shadow} from '#/state/cache/types'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderHandle({
+  profile,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+}) {
+  const t = useTheme()
+  const invalidHandle = isInvalidHandle(profile.handle)
+  const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
+  return (
+    <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
+      {profile.viewer?.followedBy && !blockHide ? (
+        <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
+          <Text style={[t.atoms.text, a.text_sm]}>
+            <Trans>Follows you</Trans>
+          </Text>
+        </View>
+      ) : undefined}
+      <Text
+        style={[
+          invalidHandle
+            ? [
+                a.border,
+                a.text_xs,
+                a.px_sm,
+                a.py_xs,
+                a.rounded_xs,
+                {borderColor: t.palette.contrast_200},
+              ]
+            : [a.text_md, t.atoms.text_contrast_medium],
+          web({wordBreak: 'break-all'}),
+        ]}>
+        {invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx
new file mode 100644
index 000000000..d9a8a01a8
--- /dev/null
+++ b/src/screens/Profile/Header/Metrics.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Shadow} from '#/state/cache/types'
+import {pluralize} from '#/lib/strings/helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {formatCount} from 'view/com/util/numeric/format'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+
+export function ProfileHeaderMetrics({
+  profile,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const following = formatCount(profile.followsCount || 0)
+  const followers = formatCount(profile.followersCount || 0)
+  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
+
+  return (
+    <View
+      style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
+      pointerEvents="box-none">
+      <InlineLink
+        testID="profileHeaderFollowersButton"
+        style={[a.flex_row, t.atoms.text]}
+        to={makeProfileLink(profile, 'followers')}
+        label={`${followers} ${pluralizedFollowers}`}>
+        <Text style={[a.font_bold, a.text_md]}>{followers} </Text>
+        <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+          {pluralizedFollowers}
+        </Text>
+      </InlineLink>
+      <InlineLink
+        testID="profileHeaderFollowsButton"
+        style={[a.flex_row, t.atoms.text]}
+        to={makeProfileLink(profile, 'follows')}
+        label={_(msg`${following} following`)}>
+        <Trans>
+          <Text style={[a.font_bold, a.text_md]}>{following} </Text>
+          <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+            following
+          </Text>
+        </Trans>
+      </InlineLink>
+      <Text style={[a.font_bold, t.atoms.text, a.text_md]}>
+        {formatCount(profile.postsCount || 0)}{' '}
+        <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
+          {pluralize(profile.postsCount || 0, 'post')}
+        </Text>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
new file mode 100644
index 000000000..6722ed09b
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -0,0 +1,329 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyLabelerDefs,
+  ModerationOpts,
+  moderateProfile,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {RichText} from '#/components/RichText'
+import {useModalControls} from '#/state/modals'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {logger} from '#/logger'
+import {Haptics} from '#/lib/haptics'
+import {pluralize} from '#/lib/strings/helpers'
+import {isAppLabeler} from '#/lib/moderation'
+
+import {atoms as a, useTheme, tokens} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {
+  Heart2_Stroke2_Corner0_Rounded as Heart,
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+} from '#/components/icons/Heart2'
+import {DialogOuterProps} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {Link} from '#/components/Link'
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderLabeler = ({
+  profile: profileUnshadowed,
+  labeler,
+  descriptionRT,
+  moderationOpts,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: Props): React.ReactNode => {
+  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
+    useProfileShadow(profileUnshadowed)
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount, hasSession} = useSession()
+  const {openModal} = useModalControls()
+  const {track} = useAnalytics()
+  const cantSubscribePrompt = Prompt.usePromptControl()
+  const isSelf = currentAccount?.did === profile.did
+
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
+  const {data: preferences} = usePreferencesQuery()
+  const {mutateAsync: toggleSubscription, variables} =
+    useLabelerSubscriptionMutation()
+  const isSubscribed =
+    variables?.subscribe ??
+    preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
+  const canSubscribe =
+    isSubscribed ||
+    (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+  const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
+  const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
+    useUnlikeMutation()
+  const [likeUri, setLikeUri] = React.useState<string>(
+    labeler.viewer?.like || '',
+  )
+  const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
+
+  const onToggleLiked = React.useCallback(async () => {
+    if (!labeler) {
+      return
+    }
+    try {
+      Haptics.default()
+
+      if (likeUri) {
+        await unlikeMod({uri: likeUri})
+        track('CustomFeed:Unlike')
+        setLikeCount(c => c - 1)
+        setLikeUri('')
+      } else {
+        const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
+        track('CustomFeed:Like')
+        setLikeCount(c => c + 1)
+        setLikeUri(res.uri)
+      }
+    } catch (e: any) {
+      Toast.show(
+        _(
+          msg`There was an an issue contacting the server, please check your internet connection and try again.`,
+        ),
+      )
+      logger.error(`Failed to toggle labeler like`, {message: e.message})
+    }
+  }, [labeler, likeUri, likeMod, unlikeMod, track, _])
+
+  const onPressEditProfile = React.useCallback(() => {
+    track('ProfileHeader:EditProfileButtonClicked')
+    openModal({
+      name: 'edit-profile',
+      profile,
+    })
+  }, [track, openModal, profile])
+
+  const onPressSubscribe = React.useCallback(async () => {
+    if (!canSubscribe) {
+      cantSubscribePrompt.open()
+      return
+    }
+    try {
+      await toggleSubscription({
+        did: profile.did,
+        subscribe: !isSubscribed,
+      })
+    } catch (e: any) {
+      // setSubscriptionError(e.message)
+      logger.error(`Failed to subscribe to labeler`, {message: e.message})
+    }
+  }, [
+    toggleSubscription,
+    isSubscribed,
+    profile,
+    canSubscribe,
+    cantSubscribePrompt,
+  ])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <ProfileHeaderShell
+      profile={profile}
+      moderation={moderation}
+      hideBackButton={hideBackButton}
+      isPlaceholderProfile={isPlaceholderProfile}>
+      <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
+        <View
+          style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
+          pointerEvents="box-none">
+          {isMe ? (
+            <Button
+              testID="profileHeaderEditProfileButton"
+              size="small"
+              color="secondary"
+              variant="solid"
+              onPress={onPressEditProfile}
+              label={_(msg`Edit profile`)}
+              style={a.rounded_full}>
+              <ButtonText>
+                <Trans>Edit Profile</Trans>
+              </ButtonText>
+            </Button>
+          ) : !isAppLabeler(profile.did) ? (
+            <>
+              <Button
+                testID="toggleSubscribeBtn"
+                label={
+                  isSubscribed
+                    ? _(msg`Unsubscribe from this labeler`)
+                    : _(msg`Subscribe to this labeler`)
+                }
+                disabled={!hasSession}
+                onPress={onPressSubscribe}>
+                {state => (
+                  <View
+                    style={[
+                      {
+                        paddingVertical: 12,
+                        backgroundColor:
+                          isSubscribed || !canSubscribe
+                            ? state.hovered || state.pressed
+                              ? t.palette.contrast_50
+                              : t.palette.contrast_25
+                            : state.hovered || state.pressed
+                            ? tokens.color.temp_purple_dark
+                            : tokens.color.temp_purple,
+                      },
+                      a.px_lg,
+                      a.rounded_sm,
+                      a.gap_sm,
+                    ]}>
+                    <Text
+                      style={[
+                        {
+                          color: canSubscribe
+                            ? isSubscribed
+                              ? t.palette.contrast_700
+                              : t.palette.white
+                            : t.palette.contrast_400,
+                        },
+                        a.font_bold,
+                        a.text_center,
+                      ]}>
+                      {isSubscribed ? (
+                        <Trans>Unsubscribe</Trans>
+                      ) : (
+                        <Trans>Subscribe to Labeler</Trans>
+                      )}
+                    </Text>
+                  </View>
+                )}
+              </Button>
+            </>
+          ) : null}
+          <ProfileMenu profile={profile} />
+        </View>
+        <View style={[a.flex_col, a.gap_xs, a.pb_md]}>
+          <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
+          <ProfileHeaderHandle profile={profile} />
+        </View>
+        {!isPlaceholderProfile && (
+          <>
+            {isSelf && <ProfileHeaderMetrics profile={profile} />}
+            {descriptionRT && !moderation.ui('profileView').blur ? (
+              <View pointerEvents="auto">
+                <RichText
+                  testID="profileHeaderDescription"
+                  style={[a.text_md]}
+                  numberOfLines={15}
+                  value={descriptionRT}
+                />
+              </View>
+            ) : undefined}
+            {!isAppLabeler(profile.did) && (
+              <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
+                <Button
+                  testID="toggleLikeBtn"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  shape="round"
+                  label={_(msg`Like this feed`)}
+                  disabled={!hasSession || isLikePending || isUnlikePending}
+                  onPress={onToggleLiked}>
+                  {likeUri ? (
+                    <HeartFilled fill={t.palette.negative_400} />
+                  ) : (
+                    <Heart fill={t.atoms.text_contrast_medium.color} />
+                  )}
+                </Button>
+
+                {typeof likeCount === 'number' && (
+                  <Link
+                    to={{
+                      screen: 'ProfileLabelerLikedBy',
+                      params: {
+                        name: labeler.creator.handle || labeler.creator.did,
+                      },
+                    }}
+                    size="tiny"
+                    label={_(
+                      msg`Liked by ${likeCount} ${pluralize(
+                        likeCount,
+                        'user',
+                      )}`,
+                    )}>
+                    {({hovered, focused, pressed}) => (
+                      <Text
+                        style={[
+                          a.font_bold,
+                          a.text_sm,
+                          t.atoms.text_contrast_medium,
+                          (hovered || focused || pressed) &&
+                            t.atoms.text_contrast_high,
+                        ]}>
+                        <Trans>
+                          Liked by {likeCount} {pluralize(likeCount, 'user')}
+                        </Trans>
+                      </Text>
+                    )}
+                  </Link>
+                )}
+              </View>
+            )}
+          </>
+        )}
+      </View>
+      <CantSubscribePrompt control={cantSubscribePrompt} />
+    </ProfileHeaderShell>
+  )
+}
+ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
+export {ProfileHeaderLabeler}
+
+function CantSubscribePrompt({
+  control,
+}: {
+  control: DialogOuterProps['control']
+}) {
+  return (
+    <Prompt.Outer control={control}>
+      <Prompt.Title>Unable to subscribe</Prompt.Title>
+      <Prompt.Description>
+        <Trans>
+          We're sorry! You can only subscribe to ten labelers, and you've
+          reached your limit of ten.
+        </Trans>
+      </Prompt.Description>
+      <Prompt.Actions>
+        <Prompt.Action onPress={control.close}>OK</Prompt.Action>
+      </Prompt.Actions>
+    </Prompt.Outer>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
new file mode 100644
index 000000000..8b9038244
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -0,0 +1,286 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  ModerationOpts,
+  moderateProfile,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {useModalControls} from '#/state/modals'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession, useRequireAuth} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {
+  useProfileFollowMutationQueue,
+  useProfileBlockMutationQueue,
+} from '#/state/queries/profile'
+import {logger} from '#/logger'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
+import {RichText} from '#/components/RichText'
+import * as Prompt from '#/components/Prompt'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderStandard = ({
+  profile: profileUnshadowed,
+  descriptionRT,
+  moderationOpts,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: Props): React.ReactNode => {
+  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
+    useProfileShadow(profileUnshadowed)
+  const t = useTheme()
+  const {currentAccount, hasSession} = useSession()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {track} = useAnalytics()
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
+  const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    'ProfileHeader',
+  )
+  const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+  const unblockPromptControl = Prompt.usePromptControl()
+  const requireAuth = useRequireAuth()
+
+  const onPressEditProfile = React.useCallback(() => {
+    track('ProfileHeader:EditProfileButtonClicked')
+    openModal({
+      name: 'edit-profile',
+      profile,
+    })
+  }, [track, openModal, profile])
+
+  const onPressFollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:FollowButtonClicked')
+        await queueFollow()
+        Toast.show(
+          _(
+            msg`Following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+              moderation.ui('displayName'),
+            )}`,
+          ),
+        )
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to follow', {message: String(e)})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
+    })
+  }
+
+  const onPressUnfollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:UnfollowButtonClicked')
+        await queueUnfollow()
+        Toast.show(
+          _(
+            msg`No longer following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+              moderation.ui('displayName'),
+            )}`,
+          ),
+        )
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to unfollow', {message: String(e)})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
+    })
+  }
+
+  const unblockAccount = React.useCallback(async () => {
+    track('ProfileHeader:UnblockAccountButtonClicked')
+    try {
+      await queueUnblock()
+      Toast.show(_(msg`Account unblocked`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to unblock account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
+      }
+    }
+  }, [_, queueUnblock, track])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <ProfileHeaderShell
+      profile={profile}
+      moderation={moderation}
+      hideBackButton={hideBackButton}
+      isPlaceholderProfile={isPlaceholderProfile}>
+      <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
+        <View
+          style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
+          pointerEvents="box-none">
+          {isMe ? (
+            <Button
+              testID="profileHeaderEditProfileButton"
+              size="small"
+              color="secondary"
+              variant="solid"
+              onPress={onPressEditProfile}
+              label={_(msg`Edit profile`)}
+              style={a.rounded_full}>
+              <ButtonText>
+                <Trans>Edit Profile</Trans>
+              </ButtonText>
+            </Button>
+          ) : profile.viewer?.blocking ? (
+            profile.viewer?.blockingByList ? null : (
+              <Button
+                testID="unblockBtn"
+                size="small"
+                color="secondary"
+                variant="solid"
+                label={_(msg`Unblock`)}
+                disabled={!hasSession}
+                onPress={() => unblockPromptControl.open()}
+                style={a.rounded_full}>
+                <ButtonText>
+                  <Trans context="action">Unblock</Trans>
+                </ButtonText>
+              </Button>
+            )
+          ) : !profile.viewer?.blockedBy ? (
+            <>
+              {hasSession && (
+                <Button
+                  testID="suggestedFollowsBtn"
+                  size="small"
+                  color={showSuggestedFollows ? 'primary' : 'secondary'}
+                  variant="solid"
+                  shape="round"
+                  onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
+                  label={_(msg`Show follows similar to ${profile.handle}`)}>
+                  <FontAwesomeIcon
+                    icon="user-plus"
+                    style={
+                      showSuggestedFollows
+                        ? {color: t.palette.white}
+                        : t.atoms.text
+                    }
+                    size={14}
+                  />
+                </Button>
+              )}
+
+              <Button
+                testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
+                size="small"
+                color={profile.viewer?.following ? 'secondary' : 'primary'}
+                variant="solid"
+                label={
+                  profile.viewer?.following
+                    ? _(msg`Unfollow ${profile.handle}`)
+                    : _(msg`Follow ${profile.handle}`)
+                }
+                disabled={!hasSession}
+                onPress={
+                  profile.viewer?.following ? onPressUnfollow : onPressFollow
+                }
+                style={[a.rounded_full, a.gap_xs]}>
+                <ButtonIcon
+                  position="left"
+                  icon={profile.viewer?.following ? Check : Plus}
+                />
+                <ButtonText>
+                  {profile.viewer?.following ? (
+                    <Trans>Following</Trans>
+                  ) : (
+                    <Trans>Follow</Trans>
+                  )}
+                </ButtonText>
+              </Button>
+            </>
+          ) : null}
+          <ProfileMenu profile={profile} />
+        </View>
+        <View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
+          <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
+          <ProfileHeaderHandle profile={profile} />
+        </View>
+        {!isPlaceholderProfile && (
+          <>
+            <ProfileHeaderMetrics profile={profile} />
+            {descriptionRT && !moderation.ui('profileView').blur ? (
+              <View pointerEvents="auto">
+                <RichText
+                  testID="profileHeaderDescription"
+                  style={[a.text_md]}
+                  numberOfLines={15}
+                  value={descriptionRT}
+                />
+              </View>
+            ) : undefined}
+          </>
+        )}
+      </View>
+      {showSuggestedFollows && (
+        <ProfileHeaderSuggestedFollows
+          actorDid={profile.did}
+          requestDismiss={() => {
+            if (showSuggestedFollows) {
+              setShowSuggestedFollows(false)
+            } else {
+              track('ProfileHeader:SuggestedFollowsOpened')
+              setShowSuggestedFollows(true)
+            }
+          }}
+        />
+      )}
+      <Prompt.Basic
+        control={unblockPromptControl}
+        title={_(msg`Unblock Account?`)}
+        description={_(
+          msg`The account will be able to interact with you after unblocking.`,
+        )}
+        onConfirm={unblockAccount}
+        confirmButtonCta={
+          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+        }
+        confirmButtonColor="negative"
+      />
+    </ProfileHeaderShell>
+  )
+}
+ProfileHeaderStandard = memo(ProfileHeaderStandard)
+export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
new file mode 100644
index 000000000..1348b394c
--- /dev/null
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -0,0 +1,164 @@
+import React, {memo} from 'react'
+import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NavigationProp} from 'lib/routes/types'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {BACK_HITSLOP} from 'lib/constants'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
+
+import {atoms as a, useTheme} from '#/alf'
+import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
+import {BlurView} from 'view/com/util/BlurView'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {UserBanner} from 'view/com/util/UserBanner'
+import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
+
+interface Props {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  moderation: ModerationDecision
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderShell = ({
+  children,
+  profile,
+  moderation,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: React.PropsWithChildren<Props>): React.ReactNode => {
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const {_} = useLingui()
+  const {openLightbox} = useLightboxControls()
+  const navigation = useNavigation<NavigationProp>()
+  const {isDesktop} = useWebMediaQueries()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressAvi = React.useCallback(() => {
+    const modui = moderation.ui('avatar')
+    if (profile.avatar && !(modui.blur && modui.noOverride)) {
+      openLightbox(new ProfileImageLightbox(profile))
+    }
+  }, [openLightbox, profile, moderation])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <View style={t.atoms.bg} pointerEvents="box-none">
+      <View pointerEvents="none">
+        {isPlaceholderProfile ? (
+          <LoadingPlaceholder
+            width="100%"
+            height={150}
+            style={{borderRadius: 0}}
+          />
+        ) : (
+          <UserBanner
+            type={profile.associated?.labeler ? 'labeler' : 'default'}
+            banner={profile.banner}
+            moderation={moderation.ui('banner')}
+          />
+        )}
+      </View>
+
+      {children}
+
+      <View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none">
+        <ProfileHeaderAlerts moderation={moderation} />
+        {isMe && (
+          <LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
+        )}
+      </View>
+
+      {!isDesktop && !hideBackButton && (
+        <TouchableWithoutFeedback
+          testID="profileHeaderBackBtn"
+          onPress={onPressBack}
+          hitSlop={BACK_HITSLOP}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <View style={styles.backBtnWrapper}>
+            <BlurView style={styles.backBtn} blurType="dark">
+              <FontAwesomeIcon size={18} icon="angle-left" color="white" />
+            </BlurView>
+          </View>
+        </TouchableWithoutFeedback>
+      )}
+      <TouchableWithoutFeedback
+        testID="profileHeaderAviButton"
+        onPress={onPressAvi}
+        accessibilityRole="image"
+        accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
+        accessibilityHint="">
+        <View
+          style={[
+            t.atoms.bg,
+            {borderColor: t.atoms.bg.backgroundColor},
+            styles.avi,
+            profile.associated?.labeler && styles.aviLabeler,
+          ]}>
+          <UserAvatar
+            type={profile.associated?.labeler ? 'labeler' : 'user'}
+            size={90}
+            avatar={profile.avatar}
+            moderation={moderation.ui('avatar')}
+          />
+        </View>
+      </TouchableWithoutFeedback>
+    </View>
+  )
+}
+ProfileHeaderShell = memo(ProfileHeaderShell)
+export {ProfileHeaderShell}
+
+const styles = StyleSheet.create({
+  backBtnWrapper: {
+    position: 'absolute',
+    top: 10,
+    left: 10,
+    width: 30,
+    height: 30,
+    overflow: 'hidden',
+    borderRadius: 15,
+    // @ts-ignore web only
+    cursor: 'pointer',
+  },
+  backBtn: {
+    width: 30,
+    height: 30,
+    borderRadius: 15,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  avi: {
+    position: 'absolute',
+    top: 110,
+    left: 10,
+    width: 94,
+    height: 94,
+    borderRadius: 47,
+    borderWidth: 2,
+  },
+  aviLabeler: {
+    borderRadius: 12,
+  },
+})
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
new file mode 100644
index 000000000..1280dd8b1
--- /dev/null
+++ b/src/screens/Profile/Header/index.tsx
@@ -0,0 +1,78 @@
+import React, {memo} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyLabelerDefs,
+  ModerationOpts,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {usePalette} from 'lib/hooks/usePalette'
+
+import {ProfileHeaderStandard} from './ProfileHeaderStandard'
+import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
+
+let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
+  const pal = usePalette('default')
+  return (
+    <View style={pal.view}>
+      <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
+      <View
+        style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
+        <LoadingPlaceholder width={80} height={80} style={styles.br40} />
+      </View>
+      <View style={styles.content}>
+        <View style={[styles.buttonsLine]}>
+          <LoadingPlaceholder width={167} height={31} style={styles.br50} />
+        </View>
+      </View>
+    </View>
+  )
+}
+ProfileHeaderLoading = memo(ProfileHeaderLoading)
+export {ProfileHeaderLoading}
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeader = (props: Props): React.ReactNode => {
+  if (props.profile.associated?.labeler) {
+    if (!props.labeler) {
+      return <ProfileHeaderLoading />
+    }
+    return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
+  }
+  return <ProfileHeaderStandard {...props} />
+}
+ProfileHeader = memo(ProfileHeader)
+export {ProfileHeader}
+
+const styles = StyleSheet.create({
+  avi: {
+    position: 'absolute',
+    top: 110,
+    left: 10,
+    width: 84,
+    height: 84,
+    borderRadius: 42,
+    borderWidth: 2,
+  },
+  content: {
+    paddingTop: 8,
+    paddingHorizontal: 14,
+    paddingBottom: 4,
+  },
+  buttonsLine: {
+    flexDirection: 'row',
+    marginLeft: 'auto',
+    marginBottom: 12,
+  },
+  br40: {borderRadius: 40},
+  br50: {borderRadius: 50},
+})
diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx
new file mode 100644
index 000000000..1d2167520
--- /dev/null
+++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {LikedByList} from '#/components/LikedByList'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+
+import {atoms as a, useBreakpoints} from '#/alf'
+
+export function ProfileLabelerLikedByScreen({
+  route,
+}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) {
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {name: handleOrDid} = route.params
+  const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  return (
+    <View
+      style={[
+        a.mx_auto,
+        a.w_full,
+        a.h_full_vh,
+        gtMobile && [
+          {
+            maxWidth: 600,
+          },
+        ],
+      ]}>
+      <ViewHeader title={_(msg`Liked By`)} />
+      <LikedByList uri={uri} />
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
new file mode 100644
index 000000000..0a5e2208d
--- /dev/null
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -0,0 +1,88 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ListRef} from 'view/com/util/List'
+import {Feed} from 'view/com/posts/Feed'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {useQueryClient} from '@tanstack/react-query'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {Text} from '#/view/com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isNative} from '#/platform/detection'
+import {SectionRef} from './types'
+
+interface FeedSectionProps {
+  feed: FeedDescriptor
+  headerHeight: number
+  isFocused: boolean
+  scrollElRef: ListRef
+  ignoreFilterFor?: string
+}
+export const ProfileFeedSection = React.forwardRef<
+  SectionRef,
+  FeedSectionProps
+>(function FeedSectionImpl(
+  {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
+  ref,
+) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const [hasNew, setHasNew] = React.useState(false)
+  const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+
+  const onScrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({
+      animated: isNative,
+      offset: -headerHeight,
+    })
+    truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+    setHasNew(false)
+  }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  const renderPostsEmpty = React.useCallback(() => {
+    return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
+  }, [_])
+
+  return (
+    <View>
+      <Feed
+        testID="postsFeed"
+        enabled={isFocused}
+        feed={feed}
+        scrollElRef={scrollElRef}
+        onHasNew={setHasNew}
+        onScrolledDownChange={setIsScrolledDown}
+        renderEmptyState={renderPostsEmpty}
+        headerOffset={headerHeight}
+        renderEndOfFeed={ProfileEndOfFeed}
+        ignoreFilterFor={ignoreFilterFor}
+      />
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onScrollToTop}
+          label={_(msg`Load new posts`)}
+          showIndicator={hasNew}
+        />
+      )}
+    </View>
+  )
+})
+
+function ProfileEndOfFeed() {
+  const pal = usePalette('default')
+
+  return (
+    <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
+      <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
+        <Trans>End of feed</Trans>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx
new file mode 100644
index 000000000..07beb9529
--- /dev/null
+++ b/src/screens/Profile/Sections/Labels.tsx
@@ -0,0 +1,233 @@
+import React from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyLabelerDefs,
+  ModerationOpts,
+  interpretLabelValueDefinitions,
+  InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
+import {ListRef} from '#/view/com/util/List'
+import {SectionRef} from './types'
+import {isNative} from '#/platform/detection'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Loader} from '#/components/Loader'
+import {Divider} from '#/components/Divider'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {ErrorState} from '../ErrorState'
+import {ModerationLabelPref} from '#/components/moderation/ModerationLabelPref'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+
+interface LabelsSectionProps {
+  isLabelerLoading: boolean
+  labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+  labelerError: Error | null
+  moderationOpts: ModerationOpts
+  scrollElRef: ListRef
+  headerHeight: number
+}
+export const ProfileLabelsSection = React.forwardRef<
+  SectionRef,
+  LabelsSectionProps
+>(function LabelsSectionImpl(
+  {
+    isLabelerLoading,
+    labelerInfo,
+    labelerError,
+    moderationOpts,
+    scrollElRef,
+    headerHeight,
+  },
+  ref,
+) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {height: minHeight} = useSafeAreaFrame()
+
+  const onScrollToTop = React.useCallback(() => {
+    // @ts-ignore TODO fix this
+    scrollElRef.current?.scrollTo({
+      animated: isNative,
+      x: 0,
+      y: -headerHeight,
+    })
+  }, [scrollElRef, headerHeight])
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  return (
+    <CenteredView>
+      <View
+        style={[
+          a.border_l,
+          a.border_r,
+          a.border_t,
+          t.atoms.border_contrast_low,
+          {
+            minHeight,
+          },
+        ]}>
+        {isLabelerLoading ? (
+          <View style={[a.w_full, a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : labelerError || !labelerInfo ? (
+          <ErrorState
+            error={
+              labelerError?.toString() ||
+              _(msg`Something went wrong, please try again.`)
+            }
+          />
+        ) : (
+          <ProfileLabelsSectionInner
+            moderationOpts={moderationOpts}
+            labelerInfo={labelerInfo}
+            scrollElRef={scrollElRef}
+            headerHeight={headerHeight}
+          />
+        )}
+      </View>
+    </CenteredView>
+  )
+})
+
+export function ProfileLabelsSectionInner({
+  moderationOpts,
+  labelerInfo,
+  scrollElRef,
+  headerHeight,
+}: {
+  moderationOpts: ModerationOpts
+  labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
+  scrollElRef: ListRef
+  headerHeight: number
+}) {
+  const t = useTheme()
+  const contextScrollHandlers = useScrollHandlers()
+
+  const scrollHandler = useAnimatedScrollHandler({
+    onBeginDrag(e, ctx) {
+      contextScrollHandlers.onBeginDrag?.(e, ctx)
+    },
+    onEndDrag(e, ctx) {
+      contextScrollHandlers.onEndDrag?.(e, ctx)
+    },
+    onScroll(e, ctx) {
+      contextScrollHandlers.onScroll?.(e, ctx)
+    },
+  })
+
+  const {labelValues} = labelerInfo.policies
+  const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
+  const labelDefs = React.useMemo(() => {
+    const customDefs = interpretLabelValueDefinitions(labelerInfo)
+    return labelValues
+      .map(val => lookupLabelValueDefinition(val, customDefs))
+      .filter(
+        def => def && def?.configurable,
+      ) as InterpretedLabelValueDefinition[]
+  }, [labelerInfo, labelValues])
+
+  return (
+    <ScrollView
+      // @ts-ignore TODO fix this
+      ref={scrollElRef}
+      scrollEventThrottle={1}
+      contentContainerStyle={{
+        paddingTop: headerHeight,
+        borderWidth: 0,
+      }}
+      contentOffset={{x: 0, y: headerHeight * -1}}
+      onScroll={scrollHandler}>
+      <View
+        style={[
+          a.pt_xl,
+          a.px_lg,
+          isNative && a.border_t,
+          t.atoms.border_contrast_low,
+        ]}>
+        <View>
+          <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
+            <Trans>
+              Labels are annotations on users and content. They can be used to
+              hide, warn, and categorize the network.
+            </Trans>
+          </Text>
+          {labelerInfo.creator.viewer?.blocking ? (
+            <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
+              <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
+              <Text
+                style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
+                <Trans>
+                  Blocking does not prevent this labeler from placing labels on
+                  your account.
+                </Trans>
+              </Text>
+            </View>
+          ) : null}
+          {labelValues.length === 0 ? (
+            <Text
+              style={[
+                a.pt_xl,
+                t.atoms.text_contrast_high,
+                a.leading_snug,
+                a.text_sm,
+              ]}>
+              <Trans>
+                This labeler hasn't declared what labels it publishes, and may
+                not be active.
+              </Trans>
+            </Text>
+          ) : !isSubscribed ? (
+            <Text
+              style={[
+                a.pt_xl,
+                t.atoms.text_contrast_high,
+                a.leading_snug,
+                a.text_sm,
+              ]}>
+              <Trans>
+                Subscribe to @{labelerInfo.creator.handle} to use these labels:
+              </Trans>
+            </Text>
+          ) : null}
+        </View>
+        {labelDefs.length > 0 && (
+          <View
+            style={[
+              a.mt_xl,
+              a.w_full,
+              a.rounded_md,
+              a.overflow_hidden,
+              t.atoms.bg_contrast_25,
+            ]}>
+            {labelDefs.map((labelDef, i) => {
+              return (
+                <React.Fragment key={labelDef.identifier}>
+                  {i !== 0 && <Divider />}
+                  <ModerationLabelPref
+                    disabled={isSubscribed ? undefined : true}
+                    labelValueDefinition={labelDef}
+                    labelerDid={labelerInfo.creator.did}
+                  />
+                </React.Fragment>
+              )
+            })}
+          </View>
+        )}
+
+        <View style={{height: 400}} />
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/screens/Profile/Sections/types.ts b/src/screens/Profile/Sections/types.ts
new file mode 100644
index 000000000..a7f77d648
--- /dev/null
+++ b/src/screens/Profile/Sections/types.ts
@@ -0,0 +1,3 @@
+export interface SectionRef {
+  scrollToTop: () => void
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index db5be0b8d..524dcb1ba 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 
 import {ImageModel} from '#/state/models/media/image'
@@ -14,32 +14,6 @@ export interface EditProfileModal {
   onUpdate?: () => void
 }
 
-export interface ModerationDetailsModal {
-  name: 'moderation-details'
-  context: 'account' | 'content'
-  moderation: ModerationUI
-}
-
-export type ReportModal = {
-  name: 'report'
-} & (
-  | {
-      uri: string
-      cid: string
-    }
-  | {did: string}
-)
-
-export type AppealLabelModal = {
-  name: 'appeal-label'
-} & (
-  | {
-      uri: string
-      cid: string
-    }
-  | {did: string}
-)
-
 export interface CreateOrEditListModal {
   name: 'create-or-edit-list'
   purpose?: string
@@ -123,10 +97,6 @@ export interface AddAppPasswordModal {
   name: 'add-app-password'
 }
 
-export interface ContentFilteringSettingsModal {
-  name: 'content-filtering-settings'
-}
-
 export interface ContentLanguagesSettingsModal {
   name: 'content-languages-settings'
 }
@@ -181,15 +151,9 @@ export type Modal =
   | SwitchAccountModal
 
   // Curation
-  | ContentFilteringSettingsModal
   | ContentLanguagesSettingsModal
   | PostLanguagesSettingsModal
 
-  // Moderation
-  | ModerationDetailsModal
-  | ReportModal
-  | AppealLabelModal
-
   // Lists
   | CreateOrEditListModal
   | UserAddRemoveListsModal
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index a442b763a..cf1d90151 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -15,6 +15,7 @@ export {
   useSetExternalEmbedPref,
 } from './external-embeds-prefs'
 export * from './hidden-posts'
+export {useLabelDefinitions} from './label-defs'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
diff --git a/src/state/preferences/label-defs.tsx b/src/state/preferences/label-defs.tsx
new file mode 100644
index 000000000..d60f8ccb8
--- /dev/null
+++ b/src/state/preferences/label-defs.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import {InterpretedLabelValueDefinition, AppBskyLabelerDefs} from '@atproto/api'
+import {useLabelDefinitionsQuery} from '../queries/preferences'
+
+interface StateContext {
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+}
+
+const stateContext = React.createContext<StateContext>({
+  labelDefs: {},
+  labelers: [],
+})
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const {labelDefs, labelers} = useLabelDefinitionsQuery()
+
+  const state = {labelDefs, labelers}
+
+  return <stateContext.Provider value={state}>{children}</stateContext.Provider>
+}
+
+export function useLabelDefinitions() {
+  return React.useContext(stateContext)
+}
diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts
index 3159ad7aa..f14b3d65f 100644
--- a/src/state/queries/actor-autocomplete.ts
+++ b/src/state/queries/actor-autocomplete.ts
@@ -6,17 +6,14 @@ import {logger} from '#/logger'
 import {getAgent} from '#/state/session'
 import {useMyFollowsQuery} from '#/state/queries/my-follows'
 import {STALE} from '#/state/queries'
-import {
-  DEFAULT_LOGGED_OUT_PREFERENCES,
-  getModerationOpts,
-  useModerationOpts,
-} from './preferences'
+import {DEFAULT_LOGGED_OUT_PREFERENCES, useModerationOpts} from './preferences'
 import {isInvalidHandle} from '#/lib/strings/handles'
+import {isJustAMute} from '#/lib/moderation'
 
-const DEFAULT_MOD_OPTS = getModerationOpts({
-  userDid: '',
-  preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
-})
+const DEFAULT_MOD_OPTS = {
+  userDid: undefined,
+  prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
+}
 
 export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix]
 
@@ -114,8 +111,8 @@ function computeSuggestions(
     }
   }
   return items.filter(profile => {
-    const mod = moderateProfile(profile, moderationOpts)
-    return !mod.account.filter && mod.account.cause?.type !== 'muted'
+    const modui = moderateProfile(profile, moderationOpts).ui('profileList')
+    return !modui.filter || isJustAMute(modui)
   })
 }
 
diff --git a/src/state/queries/labeler.ts b/src/state/queries/labeler.ts
new file mode 100644
index 000000000..c405a6b57
--- /dev/null
+++ b/src/state/queries/labeler.ts
@@ -0,0 +1,89 @@
+import {z} from 'zod'
+import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getAgent} from '#/state/session'
+import {preferencesQueryKey} from '#/state/queries/preferences'
+import {STALE, PUBLIC_BSKY_AGENT} from '#/state/queries'
+
+export const labelerInfoQueryKey = (did: string) => ['labeler-info', did]
+export const labelersInfoQueryKey = (dids: string[]) => [
+  'labelers-info',
+  dids.sort(),
+]
+export const labelersDetailedInfoQueryKey = (dids: string[]) => [
+  'labelers-detailed-info',
+  dids,
+]
+
+export function useLabelerInfoQuery({
+  did,
+  enabled,
+}: {
+  did?: string
+  enabled?: boolean
+}) {
+  return useQuery({
+    enabled: !!did && enabled !== false,
+    queryKey: labelerInfoQueryKey(did as string),
+    queryFn: async () => {
+      const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({
+        dids: [did as string],
+        detailed: true,
+      })
+      return res.data.views[0] as AppBskyLabelerDefs.LabelerViewDetailed
+    },
+  })
+}
+
+export function useLabelersInfoQuery({dids}: {dids: string[]}) {
+  return useQuery({
+    enabled: !!dids.length,
+    queryKey: labelersInfoQueryKey(dids),
+    queryFn: async () => {
+      const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({dids})
+      return res.data.views as AppBskyLabelerDefs.LabelerView[]
+    },
+  })
+}
+
+export function useLabelersDetailedInfoQuery({dids}: {dids: string[]}) {
+  return useQuery({
+    enabled: !!dids.length,
+    queryKey: labelersDetailedInfoQueryKey(dids),
+    gcTime: 1000 * 60 * 60 * 6, // 6 hours
+    staleTime: STALE.MINUTES.ONE,
+    queryFn: async () => {
+      const res = await PUBLIC_BSKY_AGENT.app.bsky.labeler.getServices({
+        dids,
+        detailed: true,
+      })
+      return res.data.views as AppBskyLabelerDefs.LabelerViewDetailed[]
+    },
+  })
+}
+
+export function useLabelerSubscriptionMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    async mutationFn({did, subscribe}: {did: string; subscribe: boolean}) {
+      // TODO
+      z.object({
+        did: z.string(),
+        subscribe: z.boolean(),
+      }).parse({did, subscribe})
+
+      if (subscribe) {
+        await getAgent().addLabeler(did)
+      } else {
+        await getAgent().removeLabeler(did)
+      }
+    },
+    onSuccess() {
+      queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 626d3e911..97fc57dc1 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,14 +1,13 @@
 import {
   AppBskyNotificationListNotifications,
   ModerationOpts,
-  moderateProfile,
+  moderateNotification,
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedRepost,
   AppBskyFeedLike,
   AppBskyEmbedRecord,
 } from '@atproto/api'
-import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import chunk from 'lodash.chunk'
 import {QueryClient} from '@tanstack/react-query'
 import {getAgent} from '../../session'
@@ -88,37 +87,20 @@ export async function fetchPage({
 // internal methods
 // =
 
-// TODO this should be in the sdk as moderateNotification -prf
-function shouldFilterNotif(
+export function shouldFilterNotif(
   notif: AppBskyNotificationListNotifications.Notification,
   moderationOpts: ModerationOpts | undefined,
 ): boolean {
   if (!moderationOpts) {
     return false
   }
-  const profile = moderateProfile(notif.author, moderationOpts)
-  if (
-    profile.account.filter ||
-    profile.profile.filter ||
-    notif.author.viewer?.muted
-  ) {
-    return true
-  }
-  if (
-    notif.type === 'reply' ||
-    notif.type === 'quote' ||
-    notif.type === 'mention'
-  ) {
-    // NOTE: the notification overlaps the post enough for this to work
-    const post = moderatePost(notif, moderationOpts)
-    if (post.content.filter) {
-      return true
-    }
+  if (notif.author.viewer?.following) {
+    return false
   }
-  return false
+  return moderateNotification(notif, moderationOpts).ui('contentList').filter
 }
 
-function groupNotifications(
+export function groupNotifications(
   notifs: AppBskyNotificationListNotifications.Notification[],
 ): FeedNotification[] {
   const groupedNotifs: FeedNotification[] = []
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index c295ffcb0..0e6eef52c 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -3,8 +3,8 @@ import {AppState} from 'react-native'
 import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
+  ModerationDecision,
   AtUri,
-  PostModeration,
 } from '@atproto/api'
 import {
   useInfiniteQuery,
@@ -29,7 +29,6 @@ import {STALE} from '#/state/queries'
 import {precacheFeedPostProfiles} from './profile'
 import {getAgent} from '#/state/session'
 import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
 import {KnownError} from '#/view/com/posts/FeedErrorMessage'
 import {embedViewRecordToPostView, getEmbeddedPost} from './util'
 import {useModerationOpts} from './preferences'
@@ -69,7 +68,7 @@ export interface FeedPostSliceItem {
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
   reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
-  moderation: PostModeration
+  moderation: ModerationDecision
 }
 
 export interface FeedPostSlice {
@@ -250,9 +249,17 @@ export function usePostFeedQuery(
 
                   // apply moderation filter
                   for (let i = 0; i < slice.items.length; i++) {
+                    const ignoreFilter =
+                      slice.items[i].post.author.did === ignoreFilterFor
+                    if (ignoreFilter) {
+                      // remove mutes to avoid confused UIs
+                      moderations[i].causes = moderations[i].causes.filter(
+                        cause => cause.type !== 'muted',
+                      )
+                    }
                     if (
-                      moderations[i]?.content.filter &&
-                      slice.items[i].post.author.did !== ignoreFilterFor
+                      !ignoreFilter &&
+                      moderations[i]?.ui('contentList').filter
                     ) {
                       return undefined
                     }
@@ -435,13 +442,12 @@ function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) {
   let somePostsPassModeration = false
 
   for (const item of feed) {
-    const moderationOpts = getModerationOpts({
-      userDid: '',
-      preferences: DEFAULT_LOGGED_OUT_PREFERENCES,
+    const moderation = moderatePost(item.post, {
+      userDid: undefined,
+      prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
     })
-    const moderation = moderatePost(item.post, moderationOpts)
 
-    if (!moderation.content.filter) {
+    if (!moderation.ui('contentList').filter) {
       // we have a sfw post
       somePostsPassModeration = true
     }
diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts
index 2cde07f28..a0498ada4 100644
--- a/src/state/queries/post-liked-by.ts
+++ b/src/state/queries/post-liked-by.ts
@@ -12,9 +12,9 @@ const PAGE_SIZE = 30
 type RQPageParam = string | undefined
 
 // TODO refactor invalidate on mutate?
-export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri]
+export const RQKEY = (resolvedUri: string) => ['liked-by', resolvedUri]
 
-export function usePostLikedByQuery(resolvedUri: string | undefined) {
+export function useLikedByQuery(resolvedUri: string | undefined) {
   return useInfiniteQuery<
     AppBskyFeedGetLikes.OutputSchema,
     Error,
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 53c9e482a..4cb4d1e96 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -29,26 +29,20 @@ export const DEFAULT_PROD_FEEDS = {
 
 export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
   birthDate: new Date('2022-11-17'), // TODO(pwi)
-  adultContentEnabled: false,
   feeds: {
     saved: [],
     pinned: [],
     unpinned: [],
   },
-  // labels are undefined until set by user
-  contentLabels: {
-    nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw,
-    nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity,
-    suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive,
-    gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore,
-    hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate,
-    spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam,
-    impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation,
+  moderationPrefs: {
+    adultContentEnabled: false,
+    labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
+    labelers: [],
+    mutedWords: [],
+    hiddenPosts: [],
   },
   feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
   threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
   userAge: 13, // TODO(pwi)
   interests: {tags: []},
-  mutedWords: [],
-  hiddenPosts: [],
 }
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 37ef10ae0..cfc5c5bbe 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -1,29 +1,27 @@
-import {useMemo} from 'react'
+import {useMemo, createContext, useContext} from 'react'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
 import {
   LabelPreference,
   BskyFeedViewPreference,
+  ModerationOpts,
   AppBskyActorDefs,
 } from '@atproto/api'
 
 import {track} from '#/lib/analytics/analytics'
 import {getAge} from '#/lib/strings/time'
-import {useSession, getAgent} from '#/state/session'
-import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
+import {getAgent, useSession} from '#/state/session'
 import {
-  ConfigurableLabelGroup,
   UsePreferencesQueryResponse,
   ThreadViewPreferences,
 } from '#/state/queries/preferences/types'
-import {temp__migrateLabelPref} from '#/state/queries/preferences/util'
 import {
   DEFAULT_HOME_FEED_PREFS,
   DEFAULT_THREAD_VIEW_PREFS,
   DEFAULT_LOGGED_OUT_PREFERENCES,
 } from '#/state/queries/preferences/const'
-import {getModerationOpts} from '#/state/queries/preferences/moderation'
 import {STALE} from '#/state/queries'
-import {useHiddenPosts} from '#/state/preferences/hidden-posts'
+import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
+import {saveLabelers} from '#/state/session/agent-config'
 
 export * from '#/state/queries/preferences/types'
 export * from '#/state/queries/preferences/moderation'
@@ -44,6 +42,13 @@ export function usePreferencesQuery() {
         return DEFAULT_LOGGED_OUT_PREFERENCES
       } else {
         const res = await agent.getPreferences()
+
+        // save to local storage to ensure there are labels on initial requests
+        saveLabelers(
+          agent.session.did,
+          res.moderationPrefs.labelers.map(l => l.did),
+        )
+
         const preferences: UsePreferencesQueryResponse = {
           ...res,
           feeds: {
@@ -54,32 +59,6 @@ export function usePreferencesQuery() {
                 return !res.feeds.pinned?.includes(f)
               }) || [],
           },
-          // labels are undefined until set by user
-          contentLabels: {
-            nsfw: temp__migrateLabelPref(
-              res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw,
-            ),
-            nudity: temp__migrateLabelPref(
-              res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity,
-            ),
-            suggestive: temp__migrateLabelPref(
-              res.contentLabels?.suggestive ||
-                DEFAULT_LABEL_PREFERENCES.suggestive,
-            ),
-            gore: temp__migrateLabelPref(
-              res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore,
-            ),
-            hate: temp__migrateLabelPref(
-              res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate,
-            ),
-            spam: temp__migrateLabelPref(
-              res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam,
-            ),
-            impersonation: temp__migrateLabelPref(
-              res.contentLabels?.impersonation ||
-                DEFAULT_LABEL_PREFERENCES.impersonation,
-            ),
-          },
           feedViewPrefs: {
             ...DEFAULT_HOME_FEED_PREFS,
             ...(res.feedViewPrefs.home || {}),
@@ -96,25 +75,30 @@ export function usePreferencesQuery() {
   })
 }
 
+// used in the moderation state devtool
+export const moderationOptsOverrideContext = createContext<
+  ModerationOpts | undefined
+>(undefined)
+
 export function useModerationOpts() {
+  const override = useContext(moderationOptsOverrideContext)
   const {currentAccount} = useSession()
   const prefs = usePreferencesQuery()
-  const hiddenPosts = useHiddenPosts()
-  const opts = useMemo(() => {
+  const {labelDefs} = useLabelDefinitions()
+  const hiddenPosts = useHiddenPosts() // TODO move this into pds-stored prefs
+  const opts = useMemo<ModerationOpts | undefined>(() => {
+    if (override) {
+      return override
+    }
     if (!prefs.data) {
       return
     }
-    const moderationOpts = getModerationOpts({
-      userDid: currentAccount?.did || '',
-      preferences: prefs.data,
-    })
-
     return {
-      ...moderationOpts,
-      hiddenPosts,
-      mutedWords: prefs.data.mutedWords || [],
+      userDid: currentAccount?.did,
+      prefs: {...prefs.data.moderationPrefs, hiddenPosts: hiddenPosts || []},
+      labelDefs,
     }
-  }, [currentAccount?.did, prefs.data, hiddenPosts])
+  }, [override, currentAccount, labelDefs, prefs.data, hiddenPosts])
   return opts
 }
 
@@ -138,10 +122,32 @@ export function usePreferencesSetContentLabelMutation() {
   return useMutation<
     void,
     unknown,
-    {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference}
+    {label: string; visibility: LabelPreference; labelerDid: string | undefined}
   >({
-    mutationFn: async ({labelGroup, visibility}) => {
-      await getAgent().setContentLabelPref(labelGroup, visibility)
+    mutationFn: async ({label, visibility, labelerDid}) => {
+      await getAgent().setContentLabelPref(label, visibility, labelerDid)
+      // triggers a refetch
+      await queryClient.invalidateQueries({
+        queryKey: preferencesQueryKey,
+      })
+    },
+  })
+}
+
+export function useSetContentLabelMutation() {
+  const queryClient = useQueryClient()
+
+  return useMutation({
+    mutationFn: async ({
+      label,
+      visibility,
+      labelerDid,
+    }: {
+      label: string
+      visibility: LabelPreference
+      labelerDid?: string
+    }) => {
+      await getAgent().setContentLabelPref(label, visibility, labelerDid)
       // triggers a refetch
       await queryClient.invalidateQueries({
         queryKey: preferencesQueryKey,
diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts
index cdae52937..9cd183e8b 100644
--- a/src/state/queries/preferences/moderation.ts
+++ b/src/state/queries/preferences/moderation.ts
@@ -1,181 +1,53 @@
+import React from 'react'
 import {
-  LabelPreference,
-  ComAtprotoLabelDefs,
-  ModerationOpts,
+  DEFAULT_LABEL_SETTINGS,
+  BskyAgent,
+  interpretLabelValueDefinitions,
 } from '@atproto/api'
 
-import {
-  LabelGroup,
-  ConfigurableLabelGroup,
-  UsePreferencesQueryResponse,
-} from '#/state/queries/preferences/types'
-
-export type Label = ComAtprotoLabelDefs.Label
-
-export type LabelGroupConfig = {
-  id: LabelGroup
-  title: string
-  isAdultImagery?: boolean
-  subtitle?: string
-  warning: string
-  values: string[]
-}
-
-export const DEFAULT_LABEL_PREFERENCES: Record<
-  ConfigurableLabelGroup,
-  LabelPreference
-> = {
-  nsfw: 'hide',
-  nudity: 'warn',
-  suggestive: 'warn',
-  gore: 'warn',
-  hate: 'hide',
-  spam: 'hide',
-  impersonation: 'hide',
-}
+import {usePreferencesQuery} from './index'
+import {useLabelersDetailedInfoQuery} from '../labeler'
 
 /**
  * More strict than our default settings for logged in users.
- *
- * TODO(pwi)
  */
-export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record<
-  ConfigurableLabelGroup,
-  LabelPreference
-> = {
-  nsfw: 'hide',
-  nudity: 'hide',
-  suggestive: 'hide',
-  gore: 'hide',
-  hate: 'hide',
-  spam: 'hide',
-  impersonation: 'hide',
-}
-
-export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = {
-  id: 'illegal',
-  title: 'Illegal Content',
-  warning: 'Illegal Content',
-  values: ['csam', 'dmca-violation', 'nudity-nonconsensual'],
-}
-
-export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = {
-  id: 'always-filter',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!filter'],
-}
-
-export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = {
-  id: 'always-warn',
-  title: 'Content Warning',
-  warning: 'Content Warning',
-  values: ['!warn', 'account-security'],
-}
-
-export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = {
-  id: 'unknown',
-  title: 'Unknown Label',
-  warning: 'Content Warning',
-  values: [],
-}
-
-export const CONFIGURABLE_LABEL_GROUPS: Record<
-  ConfigurableLabelGroup,
-  LabelGroupConfig
-> = {
-  nsfw: {
-    id: 'nsfw',
-    title: 'Explicit Sexual Images',
-    subtitle: 'i.e. pornography',
-    warning: 'Sexually Explicit',
-    values: ['porn', 'nsfl'],
-    isAdultImagery: true,
-  },
-  nudity: {
-    id: 'nudity',
-    title: 'Other Nudity',
-    subtitle: 'Including non-sexual and artistic',
-    warning: 'Nudity',
-    values: ['nudity'],
-    isAdultImagery: true,
-  },
-  suggestive: {
-    id: 'suggestive',
-    title: 'Sexually Suggestive',
-    subtitle: 'Does not include nudity',
-    warning: 'Sexually Suggestive',
-    values: ['sexual'],
-    isAdultImagery: true,
-  },
-  gore: {
-    id: 'gore',
-    title: 'Violent / Bloody',
-    subtitle: 'Gore, self-harm, torture',
-    warning: 'Violence',
-    values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
-    isAdultImagery: true,
-  },
-  hate: {
-    id: 'hate',
-    title: 'Hate Group Iconography',
-    subtitle: 'Images of terror groups, articles covering events, etc.',
-    warning: 'Hate Groups',
-    values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'],
-  },
-  spam: {
-    id: 'spam',
-    title: 'Spam',
-    subtitle: 'Excessive unwanted interactions',
-    warning: 'Spam',
-    values: ['spam'],
-  },
-  impersonation: {
-    id: 'impersonation',
-    title: 'Impersonation',
-    subtitle: 'Accounts falsely claiming to be people or orgs',
-    warning: 'Impersonation',
-    values: ['impersonation'],
-  },
-}
-
-export function getModerationOpts({
-  userDid,
-  preferences,
-}: {
-  userDid: string
-  preferences: UsePreferencesQueryResponse
-}): ModerationOpts {
-  return {
-    userDid: userDid,
-    adultContentEnabled: preferences.adultContentEnabled,
-    labels: {
-      porn: preferences.contentLabels.nsfw,
-      sexual: preferences.contentLabels.suggestive,
-      nudity: preferences.contentLabels.nudity,
-      nsfl: preferences.contentLabels.gore,
-      corpse: preferences.contentLabels.gore,
-      gore: preferences.contentLabels.gore,
-      torture: preferences.contentLabels.gore,
-      'self-harm': preferences.contentLabels.gore,
-      'intolerant-race': preferences.contentLabels.hate,
-      'intolerant-gender': preferences.contentLabels.hate,
-      'intolerant-sexual-orientation': preferences.contentLabels.hate,
-      'intolerant-religion': preferences.contentLabels.hate,
-      intolerant: preferences.contentLabels.hate,
-      'icon-intolerant': preferences.contentLabels.hate,
-      spam: preferences.contentLabels.spam,
-      impersonation: preferences.contentLabels.impersonation,
-      scam: 'warn',
-    },
-    labelers: [
-      {
-        labeler: {
-          did: '',
-          displayName: 'Bluesky Social',
-        },
-        labels: {},
-      },
-    ],
-  }
+export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
+  Object.fromEntries(
+    Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
+  )
+
+export function useMyLabelersQuery() {
+  const prefs = usePreferencesQuery()
+  const dids = Array.from(
+    new Set(
+      BskyAgent.appLabelers.concat(
+        prefs.data?.moderationPrefs.labelers.map(l => l.did) || [],
+      ),
+    ),
+  )
+  const labelers = useLabelersDetailedInfoQuery({dids})
+  const isLoading = prefs.isLoading || labelers.isLoading
+  const error = prefs.error || labelers.error
+  return React.useMemo(() => {
+    return {
+      isLoading,
+      error,
+      data: labelers.data,
+    }
+  }, [labelers, isLoading, error])
+}
+
+export function useLabelDefinitionsQuery() {
+  const labelers = useMyLabelersQuery()
+  return React.useMemo(() => {
+    return {
+      labelDefs: Object.fromEntries(
+        (labelers.data || []).map(labeler => [
+          labeler.creator.did,
+          interpretLabelValueDefinitions(labeler),
+        ]),
+      ),
+      labelers: labelers.data || [],
+    }
+  }, [labelers])
 }
diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts
index 45c9eed7d..96da16f1a 100644
--- a/src/state/queries/preferences/types.ts
+++ b/src/state/queries/preferences/types.ts
@@ -1,46 +1,13 @@
 import {
   BskyPreferences,
-  LabelPreference,
   BskyThreadViewPreference,
   BskyFeedViewPreference,
 } from '@atproto/api'
 
-export const configurableAdultLabelGroups = [
-  'nsfw',
-  'nudity',
-  'suggestive',
-  'gore',
-] as const
-
-export const configurableOtherLabelGroups = [
-  'hate',
-  'spam',
-  'impersonation',
-] as const
-
-export const configurableLabelGroups = [
-  ...configurableAdultLabelGroups,
-  ...configurableOtherLabelGroups,
-] as const
-export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
-
-export type LabelGroup =
-  | ConfigurableLabelGroup
-  | 'illegal'
-  | 'always-filter'
-  | 'always-warn'
-  | 'unknown'
-
 export type UsePreferencesQueryResponse = Omit<
   BskyPreferences,
   'contentLabels' | 'feedViewPrefs' | 'feeds'
 > & {
-  /*
-   * Content labels previously included 'show', which has been deprecated in
-   * favor of 'ignore'. The API can return legacy data from the database, and
-   * we clean up the data in `usePreferencesQuery`.
-   */
-  contentLabels: Record<ConfigurableLabelGroup, LabelPreference>
   feedViewPrefs: BskyFeedViewPreference & {
     lab_mergeFeedEnabled?: boolean
   }
diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts
deleted file mode 100644
index 7b8160c28..000000000
--- a/src/state/queries/preferences/util.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {LabelPreference} from '@atproto/api'
-
-/**
- * Content labels previously included 'show', which has been deprecated in
- * favor of 'ignore'. The API can return legacy data from the database, and
- * we clean up the data in `usePreferencesQuery`.
- *
- * @deprecated
- */
-export function temp__migrateLabelPref(
-  pref: LabelPreference | 'show',
-): LabelPreference {
-  // @ts-ignore
-  if (pref === 'show') return 'ignore'
-  return pref
-}
diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts
deleted file mode 100644
index 8fc32c33e..000000000
--- a/src/state/queries/profile-extra-info.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import {useQuery} from '@tanstack/react-query'
-
-import {getAgent} from '#/state/session'
-import {STALE} from '#/state/queries'
-
-// TODO refactor invalidate on mutate?
-export const RQKEY = (did: string) => ['profile-extra-info', did]
-
-/**
- * Fetches some additional information for the profile screen which
- * is not available in the API's ProfileView
- */
-export function useProfileExtraInfoQuery(did: string) {
-  return useQuery({
-    staleTime: STALE.MINUTES.ONE,
-    queryKey: RQKEY(did),
-    async queryFn() {
-      const [listsRes, feedsRes] = await Promise.all([
-        getAgent().app.bsky.graph.getLists({
-          actor: did,
-          limit: 1,
-        }),
-        getAgent().app.bsky.feed.getActorFeeds({
-          actor: did,
-          limit: 1,
-        }),
-      ])
-      return {
-        hasLists: listsRes.data.lists.length > 0,
-        hasFeedgens: feedsRes.data.feeds.length > 0,
-      }
-    },
-  })
-}
diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index 932226b75..45b3ebb62 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -46,7 +46,8 @@ export function useSuggestedFollowsQuery() {
 
       res.data.actors = res.data.actors
         .filter(
-          actor => !moderateProfile(actor, moderationOpts!).account.filter,
+          actor =>
+            !moderateProfile(actor, moderationOpts!).ui('profileList').filter,
         )
         .filter(actor => {
           const viewer = actor.viewer
diff --git a/src/state/session/agent-config.ts b/src/state/session/agent-config.ts
new file mode 100644
index 000000000..3ee2718a3
--- /dev/null
+++ b/src/state/session/agent-config.ts
@@ -0,0 +1,12 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+const PREFIX = 'agent-labelers'
+
+export async function saveLabelers(did: string, value: string[]) {
+  await AsyncStorage.setItem(`${PREFIX}:${did}`, JSON.stringify(value))
+}
+
+export async function readLabelers(did: string): Promise<string[] | undefined> {
+  const rawData = await AsyncStorage.getItem(`${PREFIX}:${did}`)
+  return rawData ? JSON.parse(rawData) : undefined
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 46628318c..6b1474839 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -1,8 +1,15 @@
 import React from 'react'
-import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api'
+import {
+  BskyAgent,
+  AtpPersistSessionHandler,
+  BSKY_LABELER_DID,
+} from '@atproto/api'
 import {useQueryClient} from '@tanstack/react-query'
 import {jwtDecode} from 'jwt-decode'
 
+import {IS_DEV} from '#/env'
+import {IS_TEST_USER} from '#/lib/constants'
+import {isWeb} from '#/platform/detection'
 import {networkRetry} from '#/lib/async/retry'
 import {logger} from '#/logger'
 import * as persisted from '#/state/persisted'
@@ -12,6 +19,7 @@ import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useCloseAllActiveElements} from '#/state/util'
 import {track} from '#/lib/analytics/analytics'
 import {hasProp} from '#/lib/type-guards'
+import {readLabelers} from './agent-config'
 
 let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT
 
@@ -255,6 +263,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         deactivated,
       }
 
+      await configureModeration(agent, account)
+
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
           account,
@@ -298,6 +308,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         deactivated: isSessionDeactivated(agent.session.accessJwt),
       }
 
+      await configureModeration(agent, account)
+
       agent.setPersistSessionHandler(
         createPersistSessionHandler(
           account,
@@ -309,6 +321,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
       )
 
       __globalAgent = agent
+      // @ts-ignore
+      if (IS_DEV && isWeb) window.agent = agent
       queryClient.clear()
       upsertAccount(account)
 
@@ -348,6 +362,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           {networkErrorCallback: clearCurrentAccount},
         ),
       })
+      // @ts-ignore
+      if (IS_DEV && isWeb) window.agent = agent
+      await configureModeration(agent, account)
 
       let canReusePrevSession = false
       try {
@@ -643,6 +660,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   )
 }
 
+async function configureModeration(agent: BskyAgent, account: SessionAccount) {
+  if (IS_TEST_USER(account.handle)) {
+    const did = (
+      await agent
+        .resolveHandle({handle: 'mod-authority.test'})
+        .catch(_ => undefined)
+    )?.data.did
+    if (did) {
+      console.warn('USING TEST ENV MODERATION')
+      BskyAgent.configure({appLabelers: [did]})
+    }
+  } else {
+    BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]})
+    const labelerDids = await readLabelers(account.did).catch(_ => {})
+    if (labelerDids) {
+      agent.configureLabelersHeader(
+        labelerDids.filter(did => did !== BSKY_LABELER_DID),
+      )
+    }
+  }
+}
+
 export function useSession() {
   return React.useContext(StateContext)
 }
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index c9dbfbeac..a09e8fba9 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -2,7 +2,7 @@ import React from 'react'
 import {
   AppBskyEmbedRecord,
   AppBskyRichtextFacet,
-  PostModeration,
+  ModerationDecision,
 } from '@atproto/api'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 
@@ -16,7 +16,7 @@ export interface ComposerOptsPostRef {
     avatar?: string
   }
   embed?: AppBskyEmbedRecord.ViewRecord['embed']
-  moderation?: PostModeration
+  moderation?: ModerationDecision
 }
 export interface ComposerOptsQuote {
   uri: string
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
index 7a727ec0b..840084dcb 100644
--- a/src/view/com/auth/create/state.ts
+++ b/src/view/com/auth/create/state.ts
@@ -12,7 +12,7 @@ import {createFullHandle, validateHandle} from '#/lib/strings/handles'
 import {cleanError} from '#/lib/strings/errors'
 import {useOnboardingDispatch} from '#/state/shell/onboarding'
 import {useSessionApi} from '#/state/session'
-import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
+import {DEFAULT_SERVICE, IS_TEST_USER} from '#/lib/constants'
 import {
   DEFAULT_PROD_FEEDS,
   usePreferencesSetBirthDateMutation,
@@ -147,7 +147,7 @@ export function useSubmitCreateAccount(
             : undefined,
         })
         setBirthDate({birthDate: uiState.birthDate})
-        if (IS_PROD_SERVICE(uiState.serviceUrl)) {
+        if (!IS_TEST_USER(uiState.handle)) {
           setSavedFeeds(DEFAULT_PROD_FEEDS)
         }
       } catch (e: any) {
diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
index 5f81a4d65..434b10c22 100644
--- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
+++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {View, StyleSheet, ActivityIndicator} from 'react-native'
-import {ProfileModeration, AppBskyActorDefs} from '@atproto/api'
+import {ModerationDecision, AppBskyActorDefs} from '@atproto/api'
 import {Button} from '#/view/com/util/forms/Button'
 import {usePalette} from 'lib/hooks/usePalette'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -18,7 +18,7 @@ import {logger} from '#/logger'
 
 type Props = {
   profile: AppBskyActorDefs.ProfileViewBasic
-  moderation: ProfileModeration
+  moderation: ModerationDecision
   onFollowStateChange: (props: {
     did: string
     following: boolean
@@ -62,7 +62,7 @@ function ProfileCard({
   moderation,
 }: {
   profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
-  moderation: ProfileModeration
+  moderation: ModerationDecision
   onFollowStateChange: (props: {
     did: string
     following: boolean
@@ -113,7 +113,7 @@ function ProfileCard({
           <UserAvatar
             size={40}
             avatar={profile.avatar}
-            moderation={moderation.avatar}
+            moderation={moderation.ui('avatar')}
           />
         </View>
         <View style={styles.layoutContent}>
@@ -124,7 +124,7 @@ function ProfileCard({
             lineHeight={1.2}>
             {sanitizeDisplayName(
               profile.displayName || sanitizeHandle(profile.handle),
-              moderation.profile,
+              moderation.ui('displayName'),
             )}
           </Text>
           <Text type="xl" style={[pal.textLight]} numberOfLines={1}>
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 97f8e5194..0a2692d06 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -39,7 +39,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useExternalLinkFetch} from './useExternalLinkFetch'
 import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection'
-import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
 import {GalleryModel} from 'state/models/media/gallery'
 import {Gallery} from './photos/Gallery'
 import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 39a1473a3..4832bca02 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -15,7 +15,7 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Text} from 'view/com/util/text/Text'
-import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
+import {QuoteEmbed} from 'view/com/util/post-embeds/QuoteEmbed'
 
 export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
   const pal = usePalette('default')
@@ -86,7 +86,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
       <UserAvatar
         avatar={replyTo.author.avatar}
         size={50}
-        moderation={replyTo.moderation?.avatar}
+        moderation={replyTo.moderation?.ui('avatar')}
       />
       <View style={styles.replyToPost}>
         <Text type="xl-medium" style={[pal.text]}>
@@ -103,7 +103,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
               {replyTo.text}
             </Text>
           </View>
-          {images && !replyTo.moderation?.embed.blur && (
+          {images && !replyTo.moderation?.ui('contentMedia').blur && (
             <ComposerReplyToImages images={images} showFull={showFull} />
           )}
         </View>
diff --git a/src/view/com/modals/AppealLabel.tsx b/src/view/com/modals/AppealLabel.tsx
deleted file mode 100644
index b0aaaf625..000000000
--- a/src/view/com/modals/AppealLabel.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, {useState} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ComAtprotoModerationDefs} from '@atproto/api'
-import {ScrollView, TextInput} from './util'
-import {Text} from '../util/text/Text'
-import {s, colors} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {CharProgress} from '../composer/char-progress/CharProgress'
-import {getAgent} from '#/state/session'
-import * as Toast from '../util/Toast'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-
-export const snapPoints = ['40%']
-
-type ReportComponentProps =
-  | {
-      uri: string
-      cid: string
-    }
-  | {
-      did: string
-    }
-
-export function Component(props: ReportComponentProps) {
-  const pal = usePalette('default')
-  const [details, setDetails] = useState<string>('')
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const {isMobile} = useWebMediaQueries()
-  const isAccountReport = 'did' in props
-
-  const submit = async () => {
-    try {
-      const $type = !isAccountReport
-        ? 'com.atproto.repo.strongRef'
-        : 'com.atproto.admin.defs#repoRef'
-      await getAgent().createModerationReport({
-        reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
-        subject: {
-          $type,
-          ...props,
-        },
-        reason: details,
-      })
-      Toast.show(_(msg`We'll look into your appeal promptly.`))
-    } finally {
-      closeModal()
-    }
-  }
-
-  return (
-    <View
-      style={[
-        pal.view,
-        s.flex1,
-        isMobile ? {paddingHorizontal: 12} : undefined,
-      ]}
-      testID="appealLabelModal">
-      <Text
-        type="2xl-bold"
-        style={[pal.text, s.textCenter, {paddingBottom: 8}]}>
-        <Trans>Appeal Content Warning</Trans>
-      </Text>
-      <ScrollView>
-        <View style={[pal.btn, styles.detailsInputContainer]}>
-          <TextInput
-            accessibilityLabel={_(msg`Text input field`)}
-            accessibilityHint={_(
-              msg`Please tell us why you think this content warning was incorrectly applied!`,
-            )}
-            placeholder={_(
-              msg`Please tell us why you think this content warning was incorrectly applied!`,
-            )}
-            placeholderTextColor={pal.textLight.color}
-            value={details}
-            onChangeText={setDetails}
-            autoFocus={true}
-            numberOfLines={3}
-            multiline={true}
-            textAlignVertical="top"
-            maxLength={300}
-            style={[styles.detailsInput, pal.text]}
-          />
-          <View style={styles.detailsInputBottomBar}>
-            <View style={styles.charCounter}>
-              <CharProgress count={details?.length || 0} />
-            </View>
-          </View>
-        </View>
-        <TouchableOpacity
-          testID="confirmBtn"
-          onPress={submit}
-          style={styles.btn}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Confirm`)}
-          accessibilityHint="">
-          <Text style={[s.white, s.bold, s.f18]}>
-            <Trans>Submit</Trans>
-          </Text>
-        </TouchableOpacity>
-      </ScrollView>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  detailsInputContainer: {
-    borderRadius: 8,
-    marginBottom: 8,
-  },
-  detailsInput: {
-    paddingHorizontal: 12,
-    paddingTop: 12,
-    paddingBottom: 12,
-    borderRadius: 8,
-    minHeight: 100,
-    fontSize: 16,
-  },
-  detailsInputBottomBar: {
-    alignSelf: 'flex-end',
-  },
-  charCounter: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingRight: 10,
-    paddingBottom: 8,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.blue3,
-  },
-})
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
deleted file mode 100644
index 3c7edcf0d..000000000
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-import React from 'react'
-import {LabelPreference} from '@atproto/api'
-import {StyleSheet, Pressable, View, Linking} from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
-import {ScrollView} from './util'
-import {s, colors, gradients} from 'lib/styles'
-import {Text} from '../util/text/Text'
-import {TextLink} from '../util/Link'
-import {ToggleButton} from '../util/forms/ToggleButton'
-import {Button} from '../util/forms/Button'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isIOS} from 'platform/detection'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import * as Toast from '../util/Toast'
-import {logger} from '#/logger'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {
-  usePreferencesQuery,
-  usePreferencesSetContentLabelMutation,
-  usePreferencesSetAdultContentMutation,
-  ConfigurableLabelGroup,
-  CONFIGURABLE_LABEL_GROUPS,
-  UsePreferencesQueryResponse,
-} from '#/state/queries/preferences'
-import {useDialogControl} from '#/components/Dialog'
-import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
-
-export const snapPoints = ['90%']
-
-export function Component({}: {}) {
-  const {isMobile} = useWebMediaQueries()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const {data: preferences} = usePreferencesQuery()
-
-  const onPressDone = React.useCallback(() => {
-    closeModal()
-  }, [closeModal])
-
-  return (
-    <View testID="contentFilteringModal" style={[pal.view, styles.container]}>
-      <Text style={[pal.text, styles.title]}>
-        <Trans>Content Filtering</Trans>
-      </Text>
-
-      <ScrollView style={styles.scrollContainer}>
-        <AdultContentEnabledPref />
-        <ContentLabelPref
-          preferences={preferences}
-          labelGroup="nsfw"
-          disabled={!preferences?.adultContentEnabled}
-        />
-        <ContentLabelPref
-          preferences={preferences}
-          labelGroup="nudity"
-          disabled={!preferences?.adultContentEnabled}
-        />
-        <ContentLabelPref
-          preferences={preferences}
-          labelGroup="suggestive"
-          disabled={!preferences?.adultContentEnabled}
-        />
-        <ContentLabelPref
-          preferences={preferences}
-          labelGroup="gore"
-          disabled={!preferences?.adultContentEnabled}
-        />
-        <ContentLabelPref preferences={preferences} labelGroup="hate" />
-        <ContentLabelPref preferences={preferences} labelGroup="spam" />
-        <ContentLabelPref
-          preferences={preferences}
-          labelGroup="impersonation"
-        />
-        <View style={{height: isMobile ? 60 : 0}} />
-      </ScrollView>
-
-      <View
-        style={[
-          styles.btnContainer,
-          isMobile && styles.btnContainerMobile,
-          pal.borderDark,
-        ]}>
-        <Pressable
-          testID="sendReportBtn"
-          onPress={onPressDone}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Done`)}
-          accessibilityHint="">
-          <LinearGradient
-            colors={[gradients.blueLight.start, gradients.blueLight.end]}
-            start={{x: 0, y: 0}}
-            end={{x: 1, y: 1}}
-            style={[styles.btn]}>
-            <Text style={[s.white, s.bold, s.f18]}>
-              <Trans>Done</Trans>
-            </Text>
-          </LinearGradient>
-        </Pressable>
-      </View>
-    </View>
-  )
-}
-
-function AdultContentEnabledPref() {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {data: preferences} = usePreferencesQuery()
-  const {mutate, variables} = usePreferencesSetAdultContentMutation()
-  const bithdayDialogControl = useDialogControl()
-
-  const onSetAge = React.useCallback(
-    () => bithdayDialogControl.open(),
-    [bithdayDialogControl],
-  )
-
-  const onToggleAdultContent = React.useCallback(async () => {
-    if (isIOS) return
-
-    try {
-      mutate({
-        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
-      })
-    } catch (e) {
-      Toast.show(
-        _(msg`There was an issue syncing your preferences with the server`),
-      )
-      logger.error('Failed to update preferences with server', {message: e})
-    }
-  }, [variables, preferences, mutate, _])
-
-  const onAdultContentLinkPress = React.useCallback(() => {
-    Linking.openURL('https://bsky.app/')
-  }, [])
-
-  return (
-    <View style={s.mb10}>
-      <BirthDateSettingsDialog
-        control={bithdayDialogControl}
-        preferences={preferences}
-      />
-      {isIOS ? (
-        preferences?.adultContentEnabled ? null : (
-          <Text type="md" style={pal.textLight}>
-            <Trans>
-              Adult content can only be enabled via the Web at{' '}
-              <TextLink
-                style={pal.link}
-                href=""
-                text="bsky.app"
-                onPress={onAdultContentLinkPress}
-              />
-              .
-            </Trans>
-          </Text>
-        )
-      ) : typeof preferences?.birthDate === 'undefined' ? (
-        <View style={[pal.viewLight, styles.agePrompt]}>
-          <Text type="md" style={[pal.text, {flex: 1}]}>
-            <Trans>Confirm your age to enable adult content.</Trans>
-          </Text>
-          <Button
-            type="primary"
-            label={_(msg({message: 'Set Age', context: 'action'}))}
-            onPress={onSetAge}
-          />
-        </View>
-      ) : (preferences.userAge || 0) >= 18 ? (
-        <ToggleButton
-          type="default-light"
-          label={_(msg`Enable Adult Content`)}
-          isSelected={variables?.enabled ?? preferences?.adultContentEnabled}
-          onPress={onToggleAdultContent}
-          style={styles.toggleBtn}
-        />
-      ) : (
-        <View style={[pal.viewLight, styles.agePrompt]}>
-          <Text type="md" style={[pal.text, {flex: 1}]}>
-            <Trans>You must be 18 or older to enable adult content.</Trans>
-          </Text>
-          <Button
-            type="primary"
-            label={_(msg({message: 'Set Age', context: 'action'}))}
-            onPress={onSetAge}
-          />
-        </View>
-      )}
-    </View>
-  )
-}
-
-// TODO: Refactor this component to pass labels down to each tab
-function ContentLabelPref({
-  preferences,
-  labelGroup,
-  disabled,
-}: {
-  preferences?: UsePreferencesQueryResponse
-  labelGroup: ConfigurableLabelGroup
-  disabled?: boolean
-}) {
-  const pal = usePalette('default')
-  const visibility = preferences?.contentLabels?.[labelGroup]
-  const {mutate, variables} = usePreferencesSetContentLabelMutation()
-
-  const onChange = React.useCallback(
-    (vis: LabelPreference) => {
-      mutate({labelGroup, visibility: vis})
-    },
-    [mutate, labelGroup],
-  )
-
-  return (
-    <View style={[styles.contentLabelPref, pal.border]}>
-      <View style={s.flex1}>
-        <Text type="md-medium" style={[pal.text]}>
-          {CONFIGURABLE_LABEL_GROUPS[labelGroup].title}
-        </Text>
-        {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && (
-          <Text type="sm" style={[pal.textLight]}>
-            {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle}
-          </Text>
-        )}
-      </View>
-
-      {disabled || !visibility ? (
-        <Text type="sm-bold" style={pal.textLight}>
-          <Trans context="action">Hide</Trans>
-        </Text>
-      ) : (
-        <SelectGroup
-          current={variables?.visibility || visibility}
-          onChange={onChange}
-          labelGroup={labelGroup}
-        />
-      )}
-    </View>
-  )
-}
-
-interface SelectGroupProps {
-  current: LabelPreference
-  onChange: (v: LabelPreference) => void
-  labelGroup: ConfigurableLabelGroup
-}
-
-function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
-  const {_} = useLingui()
-
-  return (
-    <View style={styles.selectableBtns}>
-      <SelectableBtn
-        current={current}
-        value="hide"
-        label={_(msg`Hide`)}
-        left
-        onChange={onChange}
-        labelGroup={labelGroup}
-      />
-      <SelectableBtn
-        current={current}
-        value="warn"
-        label={_(msg`Warn`)}
-        onChange={onChange}
-        labelGroup={labelGroup}
-      />
-      <SelectableBtn
-        current={current}
-        value="ignore"
-        label={_(msg`Show`)}
-        right
-        onChange={onChange}
-        labelGroup={labelGroup}
-      />
-    </View>
-  )
-}
-
-interface SelectableBtnProps {
-  current: string
-  value: LabelPreference
-  label: string
-  left?: boolean
-  right?: boolean
-  onChange: (v: LabelPreference) => void
-  labelGroup: ConfigurableLabelGroup
-}
-
-function SelectableBtn({
-  current,
-  value,
-  label,
-  left,
-  right,
-  onChange,
-  labelGroup,
-}: SelectableBtnProps) {
-  const pal = usePalette('default')
-  const palPrimary = usePalette('inverted')
-  const {_} = useLingui()
-
-  return (
-    <Pressable
-      style={[
-        styles.selectableBtn,
-        left && styles.selectableBtnLeft,
-        right && styles.selectableBtnRight,
-        pal.border,
-        current === value ? palPrimary.view : pal.view,
-      ]}
-      onPress={() => onChange(value)}
-      accessibilityRole="button"
-      accessibilityLabel={value}
-      accessibilityHint={_(
-        msg`Set ${value} for ${labelGroup} content moderation policy`,
-      )}>
-      <Text style={current === value ? palPrimary.text : pal.text}>
-        {label}
-      </Text>
-    </Pressable>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    fontSize: 24,
-    marginBottom: 12,
-  },
-  description: {
-    paddingHorizontal: 2,
-    marginBottom: 10,
-  },
-  scrollContainer: {
-    flex: 1,
-    paddingHorizontal: 10,
-  },
-  btnContainer: {
-    paddingTop: 10,
-    paddingHorizontal: 10,
-  },
-  btnContainerMobile: {
-    paddingBottom: 40,
-    borderTopWidth: 1,
-  },
-
-  agePrompt: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    alignItems: 'center',
-    paddingLeft: 14,
-    paddingRight: 10,
-    paddingVertical: 8,
-    borderRadius: 8,
-  },
-
-  contentLabelPref: {
-    flexDirection: 'row',
-    justifyContent: 'space-between',
-    alignItems: 'center',
-    paddingTop: 14,
-    paddingLeft: 4,
-    marginBottom: 14,
-    borderTopWidth: 1,
-  },
-
-  selectableBtns: {
-    flexDirection: 'row',
-    marginLeft: 10,
-  },
-  selectableBtn: {
-    flexDirection: 'row',
-    justifyContent: 'center',
-    borderWidth: 1,
-    borderLeftWidth: 0,
-    paddingHorizontal: 10,
-    paddingVertical: 10,
-  },
-  selectableBtnLeft: {
-    borderTopLeftRadius: 8,
-    borderBottomLeftRadius: 8,
-    borderLeftWidth: 1,
-  },
-  selectableBtnRight: {
-    borderTopRightRadius: 8,
-    borderBottomRightRadius: 8,
-  },
-
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: '100%',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.gray1,
-  },
-  toggleBtn: {
-    paddingHorizontal: 0,
-  },
-})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index e382e6fab..238cfc502 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -15,16 +15,12 @@ import * as UserAddRemoveListsModal from './UserAddRemoveLists'
 import * as ListAddUserModal from './ListAddRemoveUsers'
 import * as AltImageModal from './AltImage'
 import * as EditImageModal from './AltImage'
-import * as ReportModal from './report/Modal'
-import * as AppealLabelModal from './AppealLabel'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
-import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as ModerationDetailsModal from './ModerationDetails'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as ChangePasswordModal from './ChangePassword'
@@ -67,12 +63,6 @@ export function ModalsContainer() {
   if (activeModal?.name === 'edit-profile') {
     snapPoints = EditProfileModal.snapPoints
     element = <EditProfileModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'report') {
-    snapPoints = ReportModal.snapPoints
-    element = <ReportModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'appeal-label') {
-    snapPoints = AppealLabelModal.snapPoints
-    element = <AppealLabelModal.Component {...activeModal} />
   } else if (activeModal?.name === 'create-or-edit-list') {
     snapPoints = CreateOrEditListModal.snapPoints
     element = <CreateOrEditListModal.Component {...activeModal} />
@@ -109,18 +99,12 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'add-app-password') {
     snapPoints = AddAppPassword.snapPoints
     element = <AddAppPassword.Component />
-  } else if (activeModal?.name === 'content-filtering-settings') {
-    snapPoints = ContentFilteringSettingsModal.snapPoints
-    element = <ContentFilteringSettingsModal.Component />
   } else if (activeModal?.name === 'content-languages-settings') {
     snapPoints = ContentLanguagesSettingsModal.snapPoints
     element = <ContentLanguagesSettingsModal.Component />
   } else if (activeModal?.name === 'post-languages-settings') {
     snapPoints = PostLanguagesSettingsModal.snapPoints
     element = <PostLanguagesSettingsModal.Component />
-  } else if (activeModal?.name === 'moderation-details') {
-    snapPoints = ModerationDetailsModal.snapPoints
-    element = <ModerationDetailsModal.Component {...activeModal} />
   } else if (activeModal?.name === 'verify-email') {
     snapPoints = VerifyEmailModal.snapPoints
     element = <VerifyEmailModal.Component {...activeModal} />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 66ea2311f..7e5d548ac 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -8,8 +8,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {useModals, useModalControls} from '#/state/modals'
 import type {Modal as ModalIface} from '#/state/modals'
 import * as EditProfileModal from './EditProfile'
-import * as ReportModal from './report/Modal'
-import * as AppealLabelModal from './AppealLabel'
 import * as CreateOrEditListModal from './CreateOrEditList'
 import * as UserAddRemoveLists from './UserAddRemoveLists'
 import * as ListAddUserModal from './ListAddRemoveUsers'
@@ -23,10 +21,8 @@ import * as EditImageModal from './EditImage'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
-import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
-import * as ModerationDetailsModal from './ModerationDetails'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as ChangePasswordModal from './ChangePassword'
@@ -78,10 +74,6 @@ function Modal({modal}: {modal: ModalIface}) {
   let element
   if (modal.name === 'edit-profile') {
     element = <EditProfileModal.Component {...modal} />
-  } else if (modal.name === 'report') {
-    element = <ReportModal.Component {...modal} />
-  } else if (modal.name === 'appeal-label') {
-    element = <AppealLabelModal.Component {...modal} />
   } else if (modal.name === 'create-or-edit-list') {
     element = <CreateOrEditListModal.Component {...modal} />
   } else if (modal.name === 'user-add-remove-lists') {
@@ -104,8 +96,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <InviteCodesModal.Component />
   } else if (modal.name === 'add-app-password') {
     element = <AddAppPassword.Component />
-  } else if (modal.name === 'content-filtering-settings') {
-    element = <ContentFilteringSettingsModal.Component />
   } else if (modal.name === 'content-languages-settings') {
     element = <ContentLanguagesSettingsModal.Component />
   } else if (modal.name === 'post-languages-settings') {
@@ -114,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <AltTextImageModal.Component {...modal} />
   } else if (modal.name === 'edit-image') {
     element = <EditImageModal.Component {...modal} />
-  } else if (modal.name === 'moderation-details') {
-    element = <ModerationDetailsModal.Component {...modal} />
   } else if (modal.name === 'verify-email') {
     element = <VerifyEmailModal.Component {...modal} />
   } else if (modal.name === 'change-email') {
diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx
deleted file mode 100644
index f890d50dc..000000000
--- a/src/view/com/modals/ModerationDetails.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import {ModerationUI} from '@atproto/api'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {Text} from '../util/text/Text'
-import {TextLink} from '../util/Link'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb} from 'platform/detection'
-import {listUriToHref} from 'lib/strings/url-helpers'
-import {Button} from '../util/forms/Button'
-import {useModalControls} from '#/state/modals'
-import {useLingui} from '@lingui/react'
-import {Trans, msg} from '@lingui/macro'
-
-export const snapPoints = [300]
-
-export function Component({
-  context,
-  moderation,
-}: {
-  context: 'account' | 'content'
-  moderation: ModerationUI
-}) {
-  const {closeModal} = useModalControls()
-  const {isMobile} = useWebMediaQueries()
-  const pal = usePalette('default')
-  const {_} = useLingui()
-
-  let name
-  let description
-  if (!moderation.cause) {
-    name = _(msg`Content Warning`)
-    description = _(
-      msg`Moderator has chosen to set a general warning on the content.`,
-    )
-  } else if (moderation.cause.type === 'blocking') {
-    if (moderation.cause.source.type === 'list') {
-      const list = moderation.cause.source.list
-      name = _(msg`User Blocked by List`)
-      description = (
-        <Trans>
-          This user is included in the{' '}
-          <TextLink
-            type="2xl"
-            href={listUriToHref(list.uri)}
-            text={list.name}
-            style={pal.link}
-          />{' '}
-          list which you have blocked.
-        </Trans>
-      )
-    } else {
-      name = _(msg`User Blocked`)
-      description = _(
-        msg`You have blocked this user. You cannot view their content.`,
-      )
-    }
-  } else if (moderation.cause.type === 'blocked-by') {
-    name = _(msg`User Blocks You`)
-    description = _(
-      msg`This user has blocked you. You cannot view their content.`,
-    )
-  } else if (moderation.cause.type === 'block-other') {
-    name = _(msg`Content Not Available`)
-    description = _(
-      msg`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 = _(msg`Account Muted by List`)
-      description = (
-        <Trans>
-          This user is included in the{' '}
-          <TextLink
-            type="2xl"
-            href={listUriToHref(list.uri)}
-            text={list.name}
-            style={pal.link}
-          />{' '}
-          list which you have muted.
-        </Trans>
-      )
-    } else {
-      name = _(msg`Account Muted`)
-      description = _(msg`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,
-        {
-          paddingHorizontal: isMobile ? 14 : 0,
-        },
-        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={() => {
-          closeModal()
-        }}>
-        <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}>
-          Okay
-        </Text>
-      </Button>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-  },
-  btn: {
-    paddingVertical: 14,
-    marginTop: isWeb ? 40 : 0,
-    marginBottom: isWeb ? 0 : 40,
-  },
-})
diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx
deleted file mode 100644
index 2bc86f75e..000000000
--- a/src/view/com/modals/report/InputIssueDetails.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import React from 'react'
-import {View, TouchableOpacity, StyleSheet} from 'react-native'
-import {TextInput} from '../util'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {CharProgress} from '../../composer/char-progress/CharProgress'
-import {Text} from '../../util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-import {SendReportButton} from './SendReportButton'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export function InputIssueDetails({
-  details,
-  setDetails,
-  goBack,
-  submitReport,
-  isProcessing,
-}: {
-  details: string | undefined
-  setDetails: (v: string) => void
-  goBack: () => void
-  submitReport: () => void
-  isProcessing: boolean
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {isMobile} = useWebMediaQueries()
-
-  return (
-    <View
-      style={{
-        marginTop: isMobile ? 12 : 0,
-      }}>
-      <TouchableOpacity
-        testID="addDetailsBtn"
-        style={[s.mb10, styles.backBtn]}
-        onPress={goBack}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`Add details`)}
-        accessibilityHint="Add more details to your report">
-        <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
-        <Text style={[pal.text, s.f18, pal.link]}>
-          {' '}
-          <Trans>Back</Trans>
-        </Text>
-      </TouchableOpacity>
-      <View style={[pal.btn, styles.detailsInputContainer]}>
-        <TextInput
-          accessibilityLabel={_(msg`Text input field`)}
-          accessibilityHint="Enter a reason for reporting this post."
-          placeholder="Enter a reason or any other details here."
-          placeholderTextColor={pal.textLight.color}
-          value={details}
-          onChangeText={setDetails}
-          autoFocus={true}
-          numberOfLines={3}
-          multiline={true}
-          textAlignVertical="top"
-          maxLength={300}
-          style={[styles.detailsInput, pal.text]}
-        />
-        <View style={styles.detailsInputBottomBar}>
-          <View style={styles.charCounter}>
-            <CharProgress count={details?.length || 0} />
-          </View>
-        </View>
-      </View>
-      <SendReportButton onPress={submitReport} isProcessing={isProcessing} />
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  backBtn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-  },
-  detailsInputContainer: {
-    borderRadius: 8,
-  },
-  detailsInput: {
-    paddingHorizontal: 12,
-    paddingTop: 12,
-    paddingBottom: 12,
-    borderRadius: 8,
-    minHeight: 100,
-    fontSize: 16,
-  },
-  detailsInputBottomBar: {
-    alignSelf: 'flex-end',
-  },
-  charCounter: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingRight: 10,
-    paddingBottom: 8,
-  },
-})
diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx
deleted file mode 100644
index abbad9b40..000000000
--- a/src/view/com/modals/report/Modal.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import React, {useState, useMemo} from 'react'
-import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native'
-import {ScrollView} from 'react-native-gesture-handler'
-import {AtUri} from '@atproto/api'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {s} from 'lib/styles'
-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 {SendReportButton} from './SendReportButton'
-import {InputIssueDetails} from './InputIssueDetails'
-import {ReportReasonOptions} from './ReasonOptions'
-import {CollectionId} from './types'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {getAgent} from '#/state/session'
-
-const DMCA_LINK = 'https://bsky.social/about/support/copyright'
-
-export const snapPoints = [575]
-
-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 {closeModal} = useModalControls()
-  const pal = usePalette('default')
-  const {isMobile} = useWebMediaQueries()
-  const [isProcessing, setIsProcessing] = 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('')
-    if (!issue) {
-      return
-    }
-    setIsProcessing(true)
-    try {
-      if (issue === '__copyright__') {
-        Linking.openURL(DMCA_LINK)
-        closeModal()
-        return
-      }
-      const $type = !isAccountReport
-        ? 'com.atproto.repo.strongRef'
-        : 'com.atproto.admin.defs#repoRef'
-      await getAgent().createModerationReport({
-        reasonType: issue,
-        subject: {
-          $type,
-          ...content,
-        },
-        reason: details,
-      })
-      Toast.show("Thank you for your report! We'll look into it promptly.")
-
-      closeModal()
-      return
-    } catch (e: any) {
-      setError(cleanError(e))
-      setIsProcessing(false)
-    }
-  }
-
-  const goBack = () => {
-    setShowDetailsInput(false)
-  }
-
-  return (
-    <ScrollView testID="reportModal" style={[s.flex1, pal.view]}>
-      <View
-        style={[
-          styles.container,
-          isMobile && {
-            paddingBottom: 40,
-          },
-        ]}>
-        {showDetailsInput ? (
-          <InputIssueDetails
-            details={details}
-            setDetails={setDetails}
-            goBack={goBack}
-            submitReport={submitReport}
-            isProcessing={isProcessing}
-          />
-        ) : (
-          <SelectIssue
-            setShowDetailsInput={setShowDetailsInput}
-            error={error}
-            issue={issue}
-            setIssue={setIssue}
-            submitReport={submitReport}
-            isProcessing={isProcessing}
-            atUri={atUri}
-          />
-        )}
-      </View>
-    </ScrollView>
-  )
-}
-
-// 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,
-  setShowDetailsInput,
-  issue,
-  setIssue,
-  submitReport,
-  isProcessing,
-  atUri,
-}: {
-  error: string | undefined
-  setShowDetailsInput: (v: boolean) => void
-  issue: string | undefined
-  setIssue: (v: string) => void
-  submitReport: () => void
-  isProcessing: boolean
-  atUri: AtUri | null
-}) => {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const collectionName = getCollectionNameForReport(atUri)
-  const onSelectIssue = (v: string) => setIssue(v)
-  const goToDetails = () => {
-    if (issue === '__copyright__') {
-      Linking.openURL(DMCA_LINK)
-      return
-    }
-    setShowDetailsInput(true)
-  }
-
-  return (
-    <>
-      <Text style={[pal.text, styles.title]}>
-        <Trans>Report {collectionName}</Trans>
-      </Text>
-      <Text style={[pal.textLight, styles.description]}>
-        <Trans>What is the issue with this {collectionName}?</Trans>
-      </Text>
-      <View style={{marginBottom: 10}}>
-        <ReportReasonOptions
-          atUri={atUri}
-          selectedIssue={issue}
-          onSelectIssue={onSelectIssue}
-        />
-      </View>
-      {error ? <ErrorMessage message={error} /> : undefined}
-      {/* 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}
-            isProcessing={isProcessing}
-          />
-          <TouchableOpacity
-            testID="addDetailsBtn"
-            style={styles.addDetailsBtn}
-            onPress={goToDetails}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Add details`)}
-            accessibilityHint="Add more details to your report">
-            <Text style={[s.f18, pal.link]}>
-              <Trans>Add details to report</Trans>
-            </Text>
-          </TouchableOpacity>
-        </>
-      ) : undefined}
-    </>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    paddingHorizontal: 10,
-  },
-  title: {
-    textAlign: 'center',
-    fontWeight: 'bold',
-    fontSize: 24,
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    fontSize: 17,
-    paddingHorizontal: 22,
-    marginBottom: 10,
-  },
-  addDetailsBtn: {
-    padding: 14,
-    alignSelf: 'center',
-  },
-})
diff --git a/src/view/com/modals/report/ReasonOptions.tsx b/src/view/com/modals/report/ReasonOptions.tsx
deleted file mode 100644
index 23b49b664..000000000
--- a/src/view/com/modals/report/ReasonOptions.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-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/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx
deleted file mode 100644
index 40c239bff..000000000
--- a/src/view/com/modals/report/SendReportButton.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-import LinearGradient from 'react-native-linear-gradient'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {Text} from '../../util/text/Text'
-import {s, gradients, colors} from 'lib/styles'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-export function SendReportButton({
-  onPress,
-  isProcessing,
-}: {
-  onPress: () => void
-  isProcessing: boolean
-}) {
-  const {_} = useLingui()
-  // loading state
-  // =
-  if (isProcessing) {
-    return (
-      <View style={[styles.btn, s.mt10]}>
-        <ActivityIndicator />
-      </View>
-    )
-  }
-  return (
-    <TouchableOpacity
-      testID="sendReportBtn"
-      style={s.mt10}
-      onPress={onPress}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`Report post`)}
-      accessibilityHint={`Reports post with reason and details`}>
-      <LinearGradient
-        colors={[gradients.blueLight.start, gradients.blueLight.end]}
-        start={{x: 0, y: 0}}
-        end={{x: 1, y: 1}}
-        style={[styles.btn]}>
-        <Text style={[s.white, s.bold, s.f18]}>
-          <Trans>Send Report</Trans>
-        </Text>
-      </LinearGradient>
-    </TouchableOpacity>
-  )
-}
-
-const styles = StyleSheet.create({
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: '100%',
-    borderRadius: 32,
-    padding: 14,
-    backgroundColor: colors.gray1,
-  },
-})
diff --git a/src/view/com/modals/report/types.ts b/src/view/com/modals/report/types.ts
deleted file mode 100644
index ca947ecbd..000000000
--- a/src/view/com/modals/report/types.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-// 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 a46870265..b16554790 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -11,7 +11,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   ModerationOpts,
-  ProfileModeration,
+  ModerationDecision,
   moderateProfile,
   AppBskyEmbedRecordWithMedia,
 } from '@atproto/api'
@@ -54,7 +54,7 @@ interface Author {
   handle: string
   displayName?: string
   avatar?: string
-  moderation: ProfileModeration
+  moderation: ModerationDecision
 }
 
 let FeedItem = ({
@@ -336,7 +336,7 @@ function CondensedAuthorsList({
           did={authors[0].did}
           handle={authors[0].handle}
           avatar={authors[0].avatar}
-          moderation={authors[0].moderation.avatar}
+          moderation={authors[0].moderation.ui('avatar')}
         />
       </View>
     )
@@ -354,7 +354,7 @@ function CondensedAuthorsList({
             <UserAvatar
               size={35}
               avatar={author.avatar}
-              moderation={author.moderation.avatar}
+              moderation={author.moderation.ui('avatar')}
             />
           </View>
         ))}
@@ -412,7 +412,7 @@ function ExpandedAuthorsList({
             <UserAvatar
               size={35}
               avatar={author.avatar}
-              moderation={author.moderation.avatar}
+              moderation={author.moderation.ui('avatar')}
             />
           </View>
           <View style={s.flex1}>
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 55463dc13..0760ed7ff 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -8,7 +8,7 @@ import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
 import {logger} from '#/logger'
 import {LoadingScreen} from '../util/LoadingScreen'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
-import {usePostLikedByQuery} from '#/state/queries/post-liked-by'
+import {useLikedByQuery} from '#/state/queries/post-liked-by'
 import {cleanError} from '#/lib/strings/errors'
 
 export function PostLikedBy({uri}: {uri: string}) {
@@ -28,7 +28,7 @@ export function PostLikedBy({uri}: {uri: string}) {
     isError,
     error,
     refetch,
-  } = usePostLikedByQuery(resolvedUri?.uri)
+  } = useLikedByQuery(resolvedUri?.uri)
   const likes = useMemo(() => {
     if (data?.pages) {
       return data.pages.flatMap(page => page.likes)
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index a7ee42a94..bac7018c3 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -106,11 +106,12 @@ export function PostThread({
         ? moderatePost(rootPost, moderationOpts)
         : undefined
 
-    const cause = mod?.content.cause
-
-    return cause
-      ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
-      : false
+    return !!mod
+      ?.ui('contentList')
+      .blurs.find(
+        cause =>
+          cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
+      )
   }, [rootPost, moderationOpts])
 
   useSetTitle(
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index cd746f9a9..c073b55a0 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -5,7 +5,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   RichText as RichTextAPI,
-  PostModeration,
+  ModerationDecision,
 } from '@atproto/api'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -19,14 +19,14 @@ import {niceDate} from 'lib/strings/time'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {countLines, pluralize} from 'lib/strings/helpers'
-import {isEmbedByEmbedder} from 'lib/embeds'
 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
 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 {PostAlerts} from '../util/moderation/PostAlerts'
+import {PostHider} from '../../../components/moderation/PostHider'
+import {ContentHider} from '../../../components/moderation/ContentHider'
+import {PostAlerts} from '../../../components/moderation/PostAlerts'
+import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {ErrorMessage} from '../util/error/ErrorMessage'
 import {usePalette} from 'lib/hooks/usePalette'
 import {formatCount} from '../util/numeric/format'
@@ -147,7 +147,7 @@ let PostThreadItemLoaded = ({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
-  moderation: PostModeration
+  moderation: ModerationDecision
   treeView: boolean
   depth: number
   prevPost: ThreadPost | undefined
@@ -175,7 +175,6 @@ let PostThreadItemLoaded = ({
   const itemTitle = _(msg`Post by ${post.author.handle}`)
   const authorHref = makeProfileLink(post.author)
   const authorTitle = post.author.handle
-  const isAuthorMuted = post.author.viewer?.muted
   const likesHref = React.useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -256,7 +255,7 @@ let PostThreadItemLoaded = ({
                 did={post.author.did}
                 handle={post.author.handle}
                 avatar={post.author.avatar}
-                moderation={moderation.avatar}
+                moderation={moderation.ui('avatar')}
               />
             </View>
             <View style={styles.layoutContent}>
@@ -271,35 +270,12 @@ let PostThreadItemLoaded = ({
                     {sanitizeDisplayName(
                       post.author.displayName ||
                         sanitizeHandle(post.author.handle),
+                      moderation.ui('displayName'),
                     )}
                   </Text>
                 </Link>
               </View>
               <View style={styles.meta}>
-                {isAuthorMuted && (
-                  <View
-                    style={[
-                      pal.viewLight,
-                      {
-                        flexDirection: 'row',
-                        alignItems: 'center',
-                        gap: 4,
-                        borderRadius: 6,
-                        paddingHorizontal: 6,
-                        paddingVertical: 2,
-                        marginRight: 4,
-                      },
-                    ]}>
-                    <FontAwesomeIcon
-                      icon={['far', 'eye-slash']}
-                      size={12}
-                      color={pal.colors.textLight}
-                    />
-                    <Text type="sm-medium" style={pal.textLight}>
-                      Muted
-                    </Text>
-                  </View>
-                )}
                 <Link style={s.flex1} href={authorHref} title={authorTitle}>
                   <Text type="md" style={[pal.textLight]} numberOfLines={1}>
                     {sanitizeHandle(post.author.handle, '@')}
@@ -312,15 +288,16 @@ let PostThreadItemLoaded = ({
             )}
           </View>
           <View style={[s.pl10, s.pr10, s.pb10]}>
+            <LabelsOnMyPost post={post} />
             <ContentHider
-              moderation={moderation.content}
+              modui={moderation.ui('contentView')}
               ignoreMute
               style={styles.contentHider}
               childContainerStyle={styles.contentHiderChild}>
               <PostAlerts
-                moderation={moderation.content}
+                modui={moderation.ui('contentView')}
                 includeMute
-                style={styles.alert}
+                style={[a.pt_2xs, a.pb_sm]}
               />
               {richText?.text ? (
                 <View
@@ -338,18 +315,9 @@ let PostThreadItemLoaded = ({
                 </View>
               ) : undefined}
               {post.embed && (
-                <ContentHider
-                  moderation={moderation.embed}
-                  moderationDecisions={moderation.decisions}
-                  ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
-                  ignoreQuoteDecisions
-                  style={s.mb10}>
-                  <PostEmbeds
-                    embed={post.embed}
-                    moderation={moderation.embed}
-                    moderationDecisions={moderation.decisions}
-                  />
-                </ContentHider>
+                <View style={[a.pb_sm]}>
+                  <PostEmbeds embed={post.embed} moderation={moderation} />
+                </View>
               )}
             </ContentHider>
             <ExpandedPostDetails
@@ -432,7 +400,8 @@ let PostThreadItemLoaded = ({
           <PostHider
             testID={`postThreadItem-by-${post.author.handle}`}
             href={postHref}
-            moderation={moderation.content}
+            style={[pal.view]}
+            modui={moderation.ui('contentList')}
             iconSize={isThreadedChild ? 26 : 38}
             iconStyles={
               isThreadedChild
@@ -482,7 +451,7 @@ let PostThreadItemLoaded = ({
                     did={post.author.did}
                     handle={post.author.handle}
                     avatar={post.author.avatar}
-                    moderation={moderation.avatar}
+                    moderation={moderation.ui('avatar')}
                   />
 
                   {showChildReplyLine && (
@@ -508,19 +477,21 @@ let PostThreadItemLoaded = ({
                 }>
                 <PostMeta
                   author={post.author}
+                  moderation={moderation}
                   authorHasWarning={!!post.author.labels?.length}
                   timestamp={post.indexedAt}
                   postHref={postHref}
                   showAvatar={isThreadedChild}
-                  avatarModeration={moderation.avatar}
+                  avatarModeration={moderation.ui('avatar')}
                   avatarSize={28}
                   displayNameType="md-bold"
                   displayNameStyle={isThreadedChild && s.ml2}
                   style={isThreadedChild && s.mb2}
                 />
+                <LabelsOnMyPost post={post} />
                 <PostAlerts
-                  moderation={moderation.content}
-                  style={styles.alert}
+                  modui={moderation.ui('contentList')}
+                  style={[a.pt_xs, a.pb_sm]}
                 />
                 {richText?.text ? (
                   <View style={styles.postTextContainer}>
@@ -542,18 +513,9 @@ let PostThreadItemLoaded = ({
                   />
                 ) : undefined}
                 {post.embed && (
-                  <ContentHider
-                    style={styles.contentHider}
-                    moderation={moderation.embed}
-                    moderationDecisions={moderation.decisions}
-                    ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)}
-                    ignoreQuoteDecisions>
-                    <PostEmbeds
-                      embed={post.embed}
-                      moderation={moderation.embed}
-                      moderationDecisions={moderation.decisions}
-                    />
-                  </ContentHider>
+                  <View style={[a.pb_xs]}>
+                    <PostEmbeds embed={post.embed} moderation={moderation} />
+                  </View>
                 )}
                 <PostCtrls
                   post={post}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 7e53eb271..c7bd4ba2f 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -4,7 +4,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
-  PostModeration,
+  ModerationDecision,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
@@ -14,8 +14,9 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostEmbeds} from '../util/post-embeds'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {ContentHider} from '../util/moderation/ContentHider'
-import {PostAlerts} from '../util/moderation/PostAlerts'
+import {ContentHider} from '../../../components/moderation/ContentHider'
+import {PostAlerts} from '../../../components/moderation/PostAlerts'
+import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {Text} from '../util/text/Text'
 import {RichText} from '#/components/RichText'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -93,7 +94,7 @@ function PostInner({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
-  moderation: PostModeration
+  moderation: ModerationDecision
   showReplyLine?: boolean
   style?: StyleProp<ViewStyle>
 }) {
@@ -142,12 +143,13 @@ function PostInner({
             did={post.author.did}
             handle={post.author.handle}
             avatar={post.author.avatar}
-            moderation={moderation.avatar}
+            moderation={moderation.ui('avatar')}
           />
         </View>
         <View style={styles.layoutContent}>
           <PostMeta
             author={post.author}
+            moderation={moderation}
             authorHasWarning={!!post.author.labels?.length}
             timestamp={post.indexedAt}
             postHref={itemHref}
@@ -176,11 +178,15 @@ function PostInner({
               </Text>
             </View>
           )}
+          <LabelsOnMyPost post={post} />
           <ContentHider
-            moderation={moderation.content}
+            modui={moderation.ui('contentView')}
             style={styles.contentHider}
             childContainerStyle={styles.contentHiderChild}>
-            <PostAlerts moderation={moderation.content} style={styles.alert} />
+            <PostAlerts
+              modui={moderation.ui('contentView')}
+              style={[a.py_xs]}
+            />
             {richText.text ? (
               <View style={styles.postTextContainer}>
                 <RichText
@@ -202,17 +208,7 @@ function PostInner({
               />
             ) : undefined}
             {post.embed ? (
-              <ContentHider
-                moderation={moderation.embed}
-                moderationDecisions={moderation.decisions}
-                ignoreQuoteDecisions
-                style={styles.contentHider}>
-                <PostEmbeds
-                  embed={post.embed}
-                  moderation={moderation.embed}
-                  moderationDecisions={moderation.decisions}
-                />
-              </ContentHider>
+              <PostEmbeds embed={post.embed} moderation={moderation} />
             ) : null}
           </ContentHider>
           <PostCtrls
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index f3911da60..0706ddb9b 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -4,7 +4,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
-  PostModeration,
+  ModerationDecision,
   RichText as RichTextAPI,
 } from '@atproto/api'
 import {
@@ -18,8 +18,9 @@ import {UserInfoText} from '../util/UserInfoText'
 import {PostMeta} from '../util/PostMeta'
 import {PostCtrls} from '../util/post-ctrls/PostCtrls'
 import {PostEmbeds} from '../util/post-embeds'
-import {ContentHider} from '../util/moderation/ContentHider'
-import {PostAlerts} from '../util/moderation/PostAlerts'
+import {ContentHider} from '#/components/moderation/ContentHider'
+import {PostAlerts} from '../../../components/moderation/PostAlerts'
+import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
 import {RichText} from '#/components/RichText'
 import {PreviewableUserAvatar} from '../util/UserAvatar'
 import {s} from 'lib/styles'
@@ -27,13 +28,11 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
-import {isEmbedByEmbedder} from 'lib/embeds'
 import {MAX_POST_LINES} from 'lib/constants'
 import {countLines} from 'lib/strings/helpers'
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
 import {FeedNameText} from '../util/FeedInfoText'
-import {useSession} from '#/state/session'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {atoms as a} from '#/alf'
@@ -50,7 +49,7 @@ export function FeedItem({
   post: AppBskyFeedDefs.PostView
   record: AppBskyFeedPost.Record
   reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
-  moderation: PostModeration
+  moderation: ModerationDecision
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
@@ -100,7 +99,7 @@ let FeedItemInner = ({
   record: AppBskyFeedPost.Record
   reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
   richText: RichTextAPI
-  moderation: PostModeration
+  moderation: ModerationDecision
   isThreadChild?: boolean
   isThreadLastChild?: boolean
   isThreadParent?: boolean
@@ -108,14 +107,10 @@ let FeedItemInner = ({
   const {openComposer} = useComposerControls()
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {currentAccount} = useSession()
   const href = useMemo(() => {
     const urip = new AtUri(post.uri)
     return makeProfileLink(post.author, 'post', urip.rkey)
   }, [post.uri, post.author])
-  const isModeratedPost =
-    moderation.decisions.post.cause?.type === 'label' &&
-    moderation.decisions.post.cause.label.src !== currentAccount?.did
 
   const replyAuthorDid = useMemo(() => {
     if (!record?.reply) {
@@ -148,7 +143,7 @@ let FeedItemInner = ({
       borderColor: pal.colors.border,
       paddingBottom:
         isThreadLastChild || (!isThreadChild && !isThreadParent)
-          ? 6
+          ? 8
           : undefined,
     },
     isThreadChild ? styles.outerSmallTop : undefined,
@@ -229,6 +224,7 @@ let FeedItemInner = ({
                     numberOfLines={1}
                     text={sanitizeDisplayName(
                       reason.by.displayName || sanitizeHandle(reason.by.handle),
+                      moderation.ui('displayName'),
                     )}
                     href={makeProfileLink(reason.by)}
                   />
@@ -246,7 +242,7 @@ let FeedItemInner = ({
             did={post.author.did}
             handle={post.author.handle}
             avatar={post.author.avatar}
-            moderation={moderation.avatar}
+            moderation={moderation.ui('avatar')}
           />
           {isThreadParent && (
             <View
@@ -264,6 +260,7 @@ let FeedItemInner = ({
         <View style={styles.layoutContent}>
           <PostMeta
             author={post.author}
+            moderation={moderation}
             authorHasWarning={!!post.author.labels?.length}
             timestamp={post.indexedAt}
             postHref={href}
@@ -295,6 +292,7 @@ let FeedItemInner = ({
               </Text>
             </View>
           )}
+          <LabelsOnMyPost post={post} />
           <PostContent
             moderation={moderation}
             richText={richText}
@@ -306,9 +304,6 @@ let FeedItemInner = ({
             record={record}
             richText={richText}
             onPressReply={onPressReply}
-            showAppealLabelItem={
-              post.author.did === currentAccount?.did && isModeratedPost
-            }
             logContext="FeedItem"
           />
         </View>
@@ -324,7 +319,7 @@ let PostContent = ({
   postEmbed,
   postAuthor,
 }: {
-  moderation: PostModeration
+  moderation: ModerationDecision
   richText: RichTextAPI
   postEmbed: AppBskyFeedDefs.PostView['embed']
   postAuthor: AppBskyFeedDefs.PostView['author']
@@ -342,10 +337,10 @@ let PostContent = ({
   return (
     <ContentHider
       testID="contentHider-post"
-      moderation={moderation.content}
+      modui={moderation.ui('contentList')}
       ignoreMute
       childContainerStyle={styles.contentHiderChild}>
-      <PostAlerts moderation={moderation.content} style={styles.alert} />
+      <PostAlerts modui={moderation.ui('contentList')} style={[a.py_xs]} />
       {richText.text ? (
         <View style={styles.postTextContainer}>
           <RichText
@@ -367,19 +362,9 @@ let PostContent = ({
         />
       ) : undefined}
       {postEmbed ? (
-        <ContentHider
-          testID="contentHider-embed"
-          moderation={moderation.embed}
-          moderationDecisions={moderation.decisions}
-          ignoreMute={isEmbedByEmbedder(postEmbed, postAuthor.did)}
-          ignoreQuoteDecisions
-          style={styles.embed}>
-          <PostEmbeds
-            embed={postEmbed}
-            moderation={moderation.embed}
-            moderationDecisions={moderation.decisions}
-          />
-        </ContentHider>
+        <View style={[a.pb_sm]}>
+          <PostEmbeds embed={postEmbed} moderation={moderation} />
+        </View>
       ) : null}
     </ContentHider>
   )
diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx
index 019e6c10e..d909bda85 100644
--- a/src/view/com/profile/ProfileCard.tsx
+++ b/src/view/com/profile/ProfileCard.tsx
@@ -3,7 +3,8 @@ import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {
   AppBskyActorDefs,
   moderateProfile,
-  ProfileModeration,
+  ModerationCause,
+  ModerationDecision,
 } from '@atproto/api'
 import {Link} from '../util/Link'
 import {Text} from '../util/text/Text'
@@ -14,16 +15,13 @@ import {FollowButton} from './FollowButton'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
-import {
-  describeModerationCause,
-  getProfileModerationCauses,
-  getModerationCauseKey,
-} from 'lib/moderation'
+import {getModerationCauseKey, isJustAMute} from 'lib/moderation'
 import {Shadow} from '#/state/cache/types'
 import {useModerationOpts} from '#/state/queries/preferences'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession} from '#/state/session'
 import {Trans} from '@lingui/macro'
+import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
 
 export function ProfileCard({
   testID,
@@ -33,6 +31,7 @@ export function ProfileCard({
   noBorder,
   followers,
   renderButton,
+  onPress,
   style,
 }: {
   testID?: string
@@ -44,6 +43,7 @@ export function ProfileCard({
   renderButton?: (
     profile: Shadow<AppBskyActorDefs.ProfileViewBasic>,
   ) => React.ReactNode
+  onPress?: () => void
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -53,11 +53,8 @@ export function ProfileCard({
     return null
   }
   const moderation = moderateProfile(profile, moderationOpts)
-  if (
-    !noModFilter &&
-    moderation.account.filter &&
-    moderation.account.cause?.type !== 'muted'
-  ) {
+  const modui = moderation.ui('profileList')
+  if (!noModFilter && modui.filter && !isJustAMute(modui)) {
     return null
   }
 
@@ -73,6 +70,7 @@ export function ProfileCard({
       ]}
       href={makeProfileLink(profile)}
       title={profile.handle}
+      onBeforePress={onPress}
       asAnchor
       anchorNoUnderline>
       <View style={styles.layout}>
@@ -80,7 +78,7 @@ export function ProfileCard({
           <UserAvatar
             size={40}
             avatar={profile.avatar}
-            moderation={moderation.avatar}
+            moderation={moderation.ui('avatar')}
           />
         </View>
         <View style={styles.layoutContent}>
@@ -91,7 +89,7 @@ export function ProfileCard({
             lineHeight={1.2}>
             {sanitizeDisplayName(
               profile.displayName || sanitizeHandle(profile.handle),
-              moderation.profile,
+              moderation.ui('displayName'),
             )}
           </Text>
           <Text type="md" style={[pal.textLight]} numberOfLines={1}>
@@ -119,17 +117,17 @@ export function ProfileCard({
   )
 }
 
-function ProfileCardPills({
+export function ProfileCardPills({
   followedBy,
   moderation,
 }: {
   followedBy: boolean
-  moderation: ProfileModeration
+  moderation: ModerationDecision
 }) {
   const pal = usePalette('default')
 
-  const causes = getProfileModerationCauses(moderation)
-  if (!followedBy && !causes.length) {
+  const modui = moderation.ui('profileList')
+  if (!followedBy && !modui.inform && !modui.alert) {
     return null
   }
 
@@ -142,19 +140,41 @@ function ProfileCardPills({
           </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>
-        )
-      })}
+      {modui.alerts.map(alert => (
+        <ProfileCardPillModerationCause
+          key={getModerationCauseKey(alert)}
+          cause={alert}
+          severity="alert"
+        />
+      ))}
+      {modui.informs.map(inform => (
+        <ProfileCardPillModerationCause
+          key={getModerationCauseKey(inform)}
+          cause={inform}
+          severity="inform"
+        />
+      ))}
+    </View>
+  )
+}
+
+function ProfileCardPillModerationCause({
+  cause,
+  severity,
+}: {
+  cause: ModerationCause
+  severity: 'alert' | 'inform'
+}) {
+  const pal = usePalette('default')
+  const {name} = useModerationCauseDescription(cause)
+  return (
+    <View
+      style={[s.mt5, pal.btn, styles.pill]}
+      key={getModerationCauseKey(cause)}>
+      <Text type="xs" style={pal.text}>
+        {severity === 'alert' ? '⚠ ' : ''}
+        {name}
+      </Text>
     </View>
   )
 }
@@ -177,7 +197,7 @@ function FollowersList({
         f,
         mod: moderateProfile(f, moderationOpts),
       }))
-      .filter(({mod}) => !mod.account.filter)
+      .filter(({mod}) => !mod.ui('profileList').filter)
   }, [followers, moderationOpts])
 
   if (!followersWithMods?.length) {
@@ -199,7 +219,11 @@ function FollowersList({
       {followersWithMods.slice(0, 3).map(({f, mod}) => (
         <View key={f.did} style={styles.followedByAviContainer}>
           <View style={[styles.followedByAvi, pal.view]}>
-            <UserAvatar avatar={f.avatar} size={32} moderation={mod.avatar} />
+            <UserAvatar
+              avatar={f.avatar}
+              size={32}
+              moderation={mod.ui('avatar')}
+            />
           </View>
         </View>
       ))}
@@ -212,11 +236,13 @@ export function ProfileCardWithFollowBtn({
   noBg,
   noBorder,
   followers,
+  onPress,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
   noBg?: boolean
   noBorder?: boolean
   followers?: AppBskyActorDefs.ProfileView[] | undefined
+  onPress?: () => void
 }) {
   const {currentAccount} = useSession()
   const isMe = profile.did === currentAccount?.did
@@ -234,6 +260,7 @@ export function ProfileCardWithFollowBtn({
               <FollowButton profile={profileShadow} logContext="ProfileCard" />
             )
       }
+      onPress={onPress}
     />
   )
 }
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
deleted file mode 100644
index 17dc5ce1b..000000000
--- a/src/view/com/profile/ProfileHeader.tsx
+++ /dev/null
@@ -1,598 +0,0 @@
-import React, {memo, useMemo} from 'react'
-import {
-  StyleSheet,
-  TouchableOpacity,
-  TouchableWithoutFeedback,
-  View,
-} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useNavigation} from '@react-navigation/native'
-import {
-  AppBskyActorDefs,
-  ModerationOpts,
-  moderateProfile,
-  RichText as RichTextAPI,
-} from '@atproto/api'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {NavigationProp} from 'lib/routes/types'
-import {isNative} from 'platform/detection'
-import {BlurView} from '../util/BlurView'
-import * as Toast from '../util/Toast'
-import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
-import {Text} from '../util/text/Text'
-import {ThemedText} from '../util/text/ThemedText'
-import {RichText} from '#/components/RichText'
-import {UserAvatar} from '../util/UserAvatar'
-import {UserBanner} from '../util/UserBanner'
-import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
-import {formatCount} from '../util/numeric/format'
-import {Link} from '../util/Link'
-import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
-import {useModalControls} from '#/state/modals'
-import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
-import {
-  useProfileBlockMutationQueue,
-  useProfileFollowMutationQueue,
-} from '#/state/queries/profile'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {BACK_HITSLOP} from 'lib/constants'
-import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
-import {makeProfileLink} from 'lib/routes/links'
-import {pluralize} from 'lib/strings/helpers'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {s, colors} from 'lib/styles'
-import {logger} from '#/logger'
-import {useSession} from '#/state/session'
-import {Shadow} from '#/state/cache/types'
-import {useRequireAuth} from '#/state/session'
-import {LabelInfo} from '../util/moderation/LabelInfo'
-import {useProfileShadow} from 'state/cache/profile-shadow'
-import {atoms as a} from '#/alf'
-import {ProfileMenu} from 'view/com/profile/ProfileMenu'
-import * as Prompt from '#/components/Prompt'
-
-let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
-  const pal = usePalette('default')
-  return (
-    <View style={pal.view}>
-      <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
-      <View
-        style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-        <LoadingPlaceholder width={80} height={80} style={styles.br40} />
-      </View>
-      <View style={styles.content}>
-        <View style={[styles.buttonsLine]}>
-          <LoadingPlaceholder width={167} height={31} style={styles.br50} />
-        </View>
-      </View>
-    </View>
-  )
-}
-ProfileHeaderLoading = memo(ProfileHeaderLoading)
-export {ProfileHeaderLoading}
-
-interface Props {
-  profile: AppBskyActorDefs.ProfileViewDetailed
-  descriptionRT: RichTextAPI | null
-  moderationOpts: ModerationOpts
-  hideBackButton?: boolean
-  isPlaceholderProfile?: boolean
-}
-
-let ProfileHeader = ({
-  profile: profileUnshadowed,
-  descriptionRT,
-  moderationOpts,
-  hideBackButton = false,
-  isPlaceholderProfile,
-}: Props): React.ReactNode => {
-  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
-    useProfileShadow(profileUnshadowed)
-  const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
-  const {currentAccount, hasSession} = useSession()
-  const requireAuth = useRequireAuth()
-  const {_} = useLingui()
-  const {openModal} = useModalControls()
-  const {openLightbox} = useLightboxControls()
-  const navigation = useNavigation<NavigationProp>()
-  const {track} = useAnalytics()
-  const invalidHandle = isInvalidHandle(profile.handle)
-  const {isDesktop} = useWebMediaQueries()
-  const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
-  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
-    profile,
-    'ProfileHeader',
-  )
-  const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
-  const unblockPromptControl = Prompt.usePromptControl()
-  const moderation = useMemo(
-    () => moderateProfile(profile, moderationOpts),
-    [profile, moderationOpts],
-  )
-
-  const onPressBack = React.useCallback(() => {
-    if (navigation.canGoBack()) {
-      navigation.goBack()
-    } else {
-      navigation.navigate('Home')
-    }
-  }, [navigation])
-
-  const onPressAvi = React.useCallback(() => {
-    if (
-      profile.avatar &&
-      !(moderation.avatar.blur && moderation.avatar.noOverride)
-    ) {
-      openLightbox(new ProfileImageLightbox(profile))
-    }
-  }, [openLightbox, profile, moderation])
-
-  const onPressFollow = () => {
-    requireAuth(async () => {
-      try {
-        track('ProfileHeader:FollowButtonClicked')
-        await queueFollow()
-        Toast.show(
-          _(
-            msg`Following ${sanitizeDisplayName(
-              profile.displayName || profile.handle,
-            )}`,
-          ),
-        )
-      } catch (e: any) {
-        if (e?.name !== 'AbortError') {
-          logger.error('Failed to follow', {message: String(e)})
-          Toast.show(_(msg`There was an issue! ${e.toString()}`))
-        }
-      }
-    })
-  }
-
-  const onPressUnfollow = () => {
-    requireAuth(async () => {
-      try {
-        track('ProfileHeader:UnfollowButtonClicked')
-        await queueUnfollow()
-        Toast.show(
-          _(
-            msg`No longer following ${sanitizeDisplayName(
-              profile.displayName || profile.handle,
-            )}`,
-          ),
-        )
-      } catch (e: any) {
-        if (e?.name !== 'AbortError') {
-          logger.error('Failed to unfollow', {message: String(e)})
-          Toast.show(_(msg`There was an issue! ${e.toString()}`))
-        }
-      }
-    })
-  }
-
-  const onPressEditProfile = React.useCallback(() => {
-    track('ProfileHeader:EditProfileButtonClicked')
-    openModal({
-      name: 'edit-profile',
-      profile,
-    })
-  }, [track, openModal, profile])
-
-  const unblockAccount = React.useCallback(async () => {
-    track('ProfileHeader:UnblockAccountButtonClicked')
-    try {
-      await queueUnblock()
-      Toast.show(_(msg`Account unblocked`))
-    } catch (e: any) {
-      if (e?.name !== 'AbortError') {
-        logger.error('Failed to unblock account', {message: e})
-        Toast.show(_(msg`There was an issue! ${e.toString()}`))
-      }
-    }
-  }, [_, queueUnblock, track])
-
-  const isMe = React.useMemo(
-    () => currentAccount?.did === profile.did,
-    [currentAccount, profile],
-  )
-
-  const blockHide =
-    !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
-  const following = formatCount(profile.followsCount || 0)
-  const followers = formatCount(profile.followersCount || 0)
-  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
-
-  return (
-    <View style={[pal.view]} pointerEvents="box-none">
-      <View pointerEvents="none">
-        {isPlaceholderProfile ? (
-          <LoadingPlaceholder
-            width="100%"
-            height={150}
-            style={{borderRadius: 0}}
-          />
-        ) : (
-          <UserBanner banner={profile.banner} moderation={moderation.avatar} />
-        )}
-      </View>
-      <View style={styles.content} pointerEvents="box-none">
-        <View style={[styles.buttonsLine]} pointerEvents="box-none">
-          {isMe ? (
-            <TouchableOpacity
-              testID="profileHeaderEditProfileButton"
-              onPress={onPressEditProfile}
-              style={[styles.btn, styles.mainBtn, pal.btn]}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Edit profile`)}
-              accessibilityHint={_(
-                msg`Opens editor for profile display name, avatar, background image, and description`,
-              )}>
-              <Text type="button" style={pal.text}>
-                <Trans>Edit Profile</Trans>
-              </Text>
-            </TouchableOpacity>
-          ) : profile.viewer?.blocking ? (
-            profile.viewer?.blockingByList ? null : (
-              <TouchableOpacity
-                testID="unblockBtn"
-                onPress={() => unblockPromptControl.open()}
-                style={[styles.btn, styles.mainBtn, pal.btn]}
-                accessibilityRole="button"
-                accessibilityLabel={_(msg`Unblock`)}
-                accessibilityHint="">
-                <Text type="button" style={[pal.text, s.bold]}>
-                  <Trans context="action">Unblock</Trans>
-                </Text>
-              </TouchableOpacity>
-            )
-          ) : !profile.viewer?.blockedBy ? (
-            <>
-              {hasSession && (
-                <TouchableOpacity
-                  testID="suggestedFollowsBtn"
-                  onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
-                  style={[
-                    styles.btn,
-                    styles.mainBtn,
-                    pal.btn,
-                    {
-                      paddingHorizontal: 10,
-                      backgroundColor: showSuggestedFollows
-                        ? pal.colors.text
-                        : pal.colors.backgroundLight,
-                    },
-                  ]}
-                  accessibilityRole="button"
-                  accessibilityLabel={_(
-                    msg`Show follows similar to ${profile.handle}`,
-                  )}
-                  accessibilityHint={_(
-                    msg`Shows a list of users similar to this user.`,
-                  )}>
-                  <FontAwesomeIcon
-                    icon="user-plus"
-                    style={[
-                      pal.text,
-                      {
-                        color: showSuggestedFollows
-                          ? pal.textInverted.color
-                          : pal.text.color,
-                      },
-                    ]}
-                    size={14}
-                  />
-                </TouchableOpacity>
-              )}
-
-              {profile.viewer?.following ? (
-                <TouchableOpacity
-                  testID="unfollowBtn"
-                  onPress={onPressUnfollow}
-                  style={[styles.btn, styles.mainBtn, pal.btn]}
-                  accessibilityRole="button"
-                  accessibilityLabel={_(msg`Unfollow ${profile.handle}`)}
-                  accessibilityHint={_(
-                    msg`Hides posts from ${profile.handle} in your feed`,
-                  )}>
-                  <FontAwesomeIcon
-                    icon="check"
-                    style={[pal.text, s.mr5]}
-                    size={14}
-                  />
-                  <Text type="button" style={pal.text}>
-                    <Trans>Following</Trans>
-                  </Text>
-                </TouchableOpacity>
-              ) : (
-                <TouchableOpacity
-                  testID="followBtn"
-                  onPress={onPressFollow}
-                  style={[styles.btn, styles.mainBtn, palInverted.view]}
-                  accessibilityRole="button"
-                  accessibilityLabel={_(msg`Follow ${profile.handle}`)}
-                  accessibilityHint={_(
-                    msg`Shows posts from ${profile.handle} in your feed`,
-                  )}>
-                  <FontAwesomeIcon
-                    icon="plus"
-                    style={[palInverted.text, s.mr5]}
-                  />
-                  <Text type="button" style={[palInverted.text, s.bold]}>
-                    <Trans>Follow</Trans>
-                  </Text>
-                </TouchableOpacity>
-              )}
-            </>
-          ) : null}
-          <ProfileMenu profile={profile} />
-        </View>
-        <View pointerEvents="none">
-          <Text
-            testID="profileHeaderDisplayName"
-            type="title-2xl"
-            style={[pal.text, styles.title]}>
-            {sanitizeDisplayName(
-              profile.displayName || sanitizeHandle(profile.handle),
-              moderation.profile,
-            )}
-          </Text>
-        </View>
-        <View style={styles.handleLine} pointerEvents="none">
-          {profile.viewer?.followedBy && !blockHide ? (
-            <View style={[styles.pill, pal.btn, s.mr5]}>
-              <Text type="xs" style={[pal.text]}>
-                <Trans>Follows you</Trans>
-              </Text>
-            </View>
-          ) : undefined}
-          <ThemedText
-            type={invalidHandle ? 'xs' : 'md'}
-            fg={invalidHandle ? 'error' : 'light'}
-            border={invalidHandle ? 'error' : undefined}
-            style={[
-              invalidHandle ? styles.invalidHandle : undefined,
-              styles.handle,
-            ]}>
-            {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
-          </ThemedText>
-        </View>
-        {!isPlaceholderProfile && !blockHide && (
-          <>
-            <View style={styles.metricsLine} pointerEvents="box-none">
-              <Link
-                testID="profileHeaderFollowersButton"
-                style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(profile, 'followers')}
-                onPressOut={() =>
-                  track(`ProfileHeader:FollowersButtonClicked`, {
-                    handle: profile.handle,
-                  })
-                }
-                asAnchor
-                accessibilityLabel={`${followers} ${pluralizedFollowers}`}
-                accessibilityHint={_(msg`Opens followers list`)}>
-                <Text type="md" style={[s.bold, pal.text]}>
-                  {followers}{' '}
-                </Text>
-                <Text type="md" style={[pal.textLight]}>
-                  {pluralizedFollowers}
-                </Text>
-              </Link>
-              <Link
-                testID="profileHeaderFollowsButton"
-                style={[s.flexRow, s.mr10]}
-                href={makeProfileLink(profile, 'follows')}
-                onPressOut={() =>
-                  track(`ProfileHeader:FollowsButtonClicked`, {
-                    handle: profile.handle,
-                  })
-                }
-                asAnchor
-                accessibilityLabel={_(msg`${following} following`)}
-                accessibilityHint={_(msg`Opens following list`)}>
-                <Trans>
-                  <Text type="md" style={[s.bold, pal.text]}>
-                    {following}{' '}
-                  </Text>
-                  <Text type="md" style={[pal.textLight]}>
-                    following
-                  </Text>
-                </Trans>
-              </Link>
-              <Text type="md" style={[s.bold, pal.text]}>
-                {formatCount(profile.postsCount || 0)}{' '}
-                <Text type="md" style={[pal.textLight]}>
-                  {pluralize(profile.postsCount || 0, 'post')}
-                </Text>
-              </Text>
-            </View>
-            {descriptionRT && !moderation.profile.blur ? (
-              <View pointerEvents="auto" style={[styles.description]}>
-                <RichText
-                  testID="profileHeaderDescription"
-                  style={[a.text_md]}
-                  numberOfLines={15}
-                  value={descriptionRT}
-                />
-              </View>
-            ) : undefined}
-          </>
-        )}
-        <ProfileHeaderAlerts moderation={moderation} />
-        {isMe && (
-          <LabelInfo details={{did: profile.did}} labels={profile.labels} />
-        )}
-      </View>
-
-      {showSuggestedFollows && (
-        <ProfileHeaderSuggestedFollows
-          actorDid={profile.did}
-          requestDismiss={() => {
-            if (showSuggestedFollows) {
-              setShowSuggestedFollows(false)
-            } else {
-              track('ProfileHeader:SuggestedFollowsOpened')
-              setShowSuggestedFollows(true)
-            }
-          }}
-        />
-      )}
-
-      {!isDesktop && !hideBackButton && (
-        <TouchableWithoutFeedback
-          testID="profileHeaderBackBtn"
-          onPress={onPressBack}
-          hitSlop={BACK_HITSLOP}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Back`)}
-          accessibilityHint="">
-          <View style={styles.backBtnWrapper}>
-            <BlurView style={styles.backBtn} blurType="dark">
-              <FontAwesomeIcon size={18} icon="angle-left" style={s.white} />
-            </BlurView>
-          </View>
-        </TouchableWithoutFeedback>
-      )}
-      <TouchableWithoutFeedback
-        testID="profileHeaderAviButton"
-        onPress={onPressAvi}
-        accessibilityRole="image"
-        accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
-        accessibilityHint="">
-        <View
-          style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
-          <UserAvatar
-            size={80}
-            avatar={profile.avatar}
-            moderation={moderation.avatar}
-          />
-        </View>
-      </TouchableWithoutFeedback>
-      <Prompt.Basic
-        control={unblockPromptControl}
-        title={_(msg`Unblock Account?`)}
-        description={_(
-          msg`The account will be able to interact with you after unblocking.`,
-        )}
-        onConfirm={unblockAccount}
-        confirmButtonCta={
-          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
-        }
-        confirmButtonColor="negative"
-      />
-    </View>
-  )
-}
-ProfileHeader = memo(ProfileHeader)
-export {ProfileHeader}
-
-const styles = StyleSheet.create({
-  banner: {
-    width: '100%',
-    height: 120,
-  },
-  backBtnWrapper: {
-    position: 'absolute',
-    top: 10,
-    left: 10,
-    width: 30,
-    height: 30,
-    overflow: 'hidden',
-    borderRadius: 15,
-    // @ts-ignore web only
-    cursor: 'pointer',
-  },
-  backBtn: {
-    width: 30,
-    height: 30,
-    borderRadius: 15,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  avi: {
-    position: 'absolute',
-    top: 110,
-    left: 10,
-    width: 84,
-    height: 84,
-    borderRadius: 42,
-    borderWidth: 2,
-  },
-  content: {
-    paddingTop: 8,
-    paddingHorizontal: 14,
-    paddingBottom: 4,
-  },
-
-  buttonsLine: {
-    flexDirection: 'row',
-    marginLeft: 'auto',
-    marginBottom: 12,
-  },
-  primaryBtn: {
-    backgroundColor: colors.blue3,
-    paddingHorizontal: 24,
-    paddingVertical: 6,
-  },
-  mainBtn: {
-    paddingHorizontal: 24,
-  },
-  secondaryBtn: {
-    paddingHorizontal: 14,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    paddingVertical: 7,
-    borderRadius: 50,
-    marginLeft: 6,
-  },
-  title: {lineHeight: 38},
-
-  // Word wrapping appears fine on
-  // mobile but overflows on desktop
-  handle: isNative
-    ? {}
-    : {
-        // @ts-ignore web only -prf
-        wordBreak: 'break-all',
-      },
-  invalidHandle: {
-    borderWidth: 1,
-    borderRadius: 4,
-    paddingHorizontal: 4,
-  },
-
-  handleLine: {
-    flexDirection: 'row',
-    marginBottom: 8,
-  },
-
-  metricsLine: {
-    flexDirection: 'row',
-    marginBottom: 8,
-  },
-
-  description: {
-    marginBottom: 8,
-  },
-
-  detailLine: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    marginBottom: 5,
-  },
-
-  pill: {
-    borderRadius: 4,
-    paddingHorizontal: 6,
-    paddingVertical: 2,
-  },
-
-  br40: {borderRadius: 40},
-  br50: {borderRadius: 50},
-})
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index 8f2c89499..947d6e9cc 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -217,7 +217,7 @@ function SuggestedFollow({
         <UserAvatar
           size={60}
           avatar={profile.avatar}
-          moderation={moderation.avatar}
+          moderation={moderation.ui('avatar')}
         />
 
         <View style={{width: '100%', paddingVertical: 12}}>
@@ -227,7 +227,7 @@ function SuggestedFollow({
             numberOfLines={1}>
             {sanitizeDisplayName(
               profile.displayName || sanitizeHandle(profile.handle),
-              moderation.profile,
+              moderation.ui('displayName'),
             )}
           </Text>
           <Text
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
index 0baa4f394..cb0b1d97c 100644
--- a/src/view/com/profile/ProfileMenu.tsx
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -17,6 +17,7 @@ import {toShareUrl} from 'lib/strings/url-helpers'
 import {makeProfileLink} from 'lib/routes/links'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useModalControls} from 'state/modals'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 import {
   RQKEY as profileQueryKey,
   useProfileBlockMutationQueue,
@@ -31,6 +32,7 @@ import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
 import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
 import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
 import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {logger} from '#/logger'
 import {Shadow} from 'state/cache/types'
 import * as Prompt from '#/components/Prompt'
@@ -47,12 +49,17 @@ let ProfileMenu = ({
   const pal = usePalette('default')
   const {track} = useAnalytics()
   const {openModal} = useModalControls()
+  const reportDialogControl = useReportDialogControl()
   const queryClient = useQueryClient()
   const isSelf = currentAccount?.did === profile.did
+  const isFollowing = profile.viewer?.following
+  const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy
+  const isFollowingBlockedAccount = isFollowing && isBlocked
+  const isLabelerAndNotBlocked = !!profile.associated?.labeler && !isBlocked
 
   const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
   const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
-  const [, queueUnfollow] = useProfileFollowMutationQueue(
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
     profile,
     'ProfileMenu',
   )
@@ -139,6 +146,19 @@ let ProfileMenu = ({
     }
   }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
 
+  const onPressFollowAccount = React.useCallback(async () => {
+    track('ProfileHeader:FollowButtonClicked')
+    try {
+      await queueFollow()
+      Toast.show(_(msg`Account followed`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to follow account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
+      }
+    }
+  }, [_, queueFollow, track])
+
   const onPressUnfollowAccount = React.useCallback(async () => {
     track('ProfileHeader:UnfollowButtonClicked')
     try {
@@ -154,11 +174,8 @@ let ProfileMenu = ({
 
   const onPressReportAccount = React.useCallback(() => {
     track('ProfileHeader:ReportAccountButtonClicked')
-    openModal({
-      name: 'report',
-      did: profile.did,
-    })
-  }, [track, openModal, profile])
+    reportDialogControl.open()
+  }, [track, reportDialogControl])
 
   return (
     <EventStopper onKeyDown={false}>
@@ -175,10 +192,9 @@ let ProfileMenu = ({
                     flexDirection: 'row',
                     alignItems: 'center',
                     justifyContent: 'center',
-                    paddingVertical: 7,
+                    paddingVertical: 10,
                     borderRadius: 50,
-                    marginLeft: 6,
-                    paddingHorizontal: 14,
+                    paddingHorizontal: 16,
                   },
                   pal.btn,
                 ]}>
@@ -210,10 +226,38 @@ let ProfileMenu = ({
               <Menu.ItemIcon icon={Share} />
             </Menu.Item>
           </Menu.Group>
+
           {hasSession && (
             <>
               <Menu.Divider />
               <Menu.Group>
+                {!isSelf && (
+                  <>
+                    {(isLabelerAndNotBlocked || isFollowingBlockedAccount) && (
+                      <Menu.Item
+                        testID="profileHeaderDropdownFollowBtn"
+                        label={
+                          isFollowing
+                            ? _(msg`Unfollow Account`)
+                            : _(msg`Follow Account`)
+                        }
+                        onPress={
+                          isFollowing
+                            ? onPressUnfollowAccount
+                            : onPressFollowAccount
+                        }>
+                        <Menu.ItemText>
+                          {isFollowing ? (
+                            <Trans>Unfollow Account</Trans>
+                          ) : (
+                            <Trans>Follow Account</Trans>
+                          )}
+                        </Menu.ItemText>
+                        <Menu.ItemIcon icon={isFollowing ? UserMinus : Plus} />
+                      </Menu.Item>
+                    )}
+                  </>
+                )}
                 <Menu.Item
                   testID="profileHeaderDropdownListAddRemoveBtn"
                   label={_(msg`Add to Lists`)}
@@ -225,18 +269,6 @@ let ProfileMenu = ({
                 </Menu.Item>
                 {!isSelf && (
                   <>
-                    {profile.viewer?.following &&
-                      (profile.viewer.blocking || profile.viewer.blockedBy) && (
-                        <Menu.Item
-                          testID="profileHeaderDropdownUnfollowBtn"
-                          label={_(msg`Unfollow Account`)}
-                          onPress={onPressUnfollowAccount}>
-                          <Menu.ItemText>
-                            <Trans>Unfollow Account</Trans>
-                          </Menu.ItemText>
-                          <Menu.ItemIcon icon={UserMinus} />
-                        </Menu.Item>
-                      )}
                     {!profile.viewer?.blocking &&
                       !profile.viewer?.mutedByList && (
                         <Menu.Item
@@ -299,6 +331,11 @@ let ProfileMenu = ({
         </Menu.Outer>
       </Menu.Root>
 
+      <ReportDialog
+        control={reportDialogControl}
+        params={{type: 'account', did: profile.did}}
+      />
+
       <Prompt.Basic
         control={blockPromptControl}
         title={
@@ -311,6 +348,10 @@ let ProfileMenu = ({
             ? _(
                 msg`The account will be able to interact with you after unblocking.`,
               )
+            : profile.associated?.labeler
+            ? _(
+                msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`,
+              )
             : _(
                 msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
               )
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 7468111b5..b6c512b09 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -47,6 +47,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> {
   anchorNoUnderline?: boolean
   navigationAction?: 'push' | 'replace' | 'navigate'
   onPointerEnter?: () => void
+  onBeforePress?: () => void
 }
 
 export const Link = memo(function Link({
@@ -60,6 +61,7 @@ export const Link = memo(function Link({
   accessible,
   anchorNoUnderline,
   navigationAction,
+  onBeforePress,
   ...props
 }: Props) {
   const t = useTheme()
@@ -70,6 +72,7 @@ export const Link = memo(function Link({
 
   const onPress = React.useCallback(
     (e?: Event) => {
+      onBeforePress?.()
       if (typeof href === 'string') {
         return onPressInner(
           closeModal,
@@ -81,7 +84,7 @@ export const Link = memo(function Link({
         )
       }
     },
-    [closeModal, navigation, navigationAction, href, openLink],
+    [closeModal, navigation, navigationAction, href, openLink, onBeforePress],
   )
 
   if (noFeedback) {
@@ -262,6 +265,7 @@ interface TextLinkOnWebOnlyProps extends TextProps {
   accessibilityHint?: string
   title?: string
   navigationAction?: 'push' | 'replace' | 'navigate'
+  disableMismatchWarning?: boolean
   onPointerEnter?: () => void
 }
 export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
@@ -273,6 +277,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
   numberOfLines,
   lineHeight,
   navigationAction,
+  disableMismatchWarning,
   ...props
 }: TextLinkOnWebOnlyProps) {
   if (isWeb) {
@@ -287,6 +292,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({
         lineHeight={lineHeight}
         title={props.title}
         navigationAction={navigationAction}
+        disableMismatchWarning={disableMismatchWarning}
         {...props}
       />
     )
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 3795dcf13..53dc20e71 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -11,7 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
 import {isAndroid, isWeb} from 'platform/detection'
 import {TimeElapsed} from './TimeElapsed'
 import {makeProfileLink} from 'lib/routes/links'
-import {ModerationUI} from '@atproto/api'
+import {ModerationDecision, ModerationUI} from '@atproto/api'
 import {usePrefetchProfileQuery} from '#/state/queries/profile'
 
 interface PostMetaOpts {
@@ -21,6 +21,7 @@ interface PostMetaOpts {
     handle: string
     displayName?: string | undefined
   }
+  moderation: ModerationDecision | undefined
   authorHasWarning: boolean
   postHref: string
   timestamp: string
@@ -55,9 +56,14 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
           style={[pal.text, opts.displayNameStyle]}
           numberOfLines={1}
           lineHeight={1.2}
+          disableMismatchWarning
           text={
             <>
-              {sanitizeDisplayName(displayName)}&nbsp;
+              {sanitizeDisplayName(
+                displayName,
+                opts.moderation?.ui('displayName'),
+              )}
+              &nbsp;
               <Text
                 type="md"
                 numberOfLines={1}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 413237397..39bc72303 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -24,9 +24,9 @@ import {
 } from '#/components/icons/Camera'
 import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
-import {useTheme} from '#/alf'
+import {useTheme, tokens} from '#/alf'
 
-export type UserAvatarType = 'user' | 'algo' | 'list'
+export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
 
 interface BaseUserAvatarProps {
   type?: UserAvatarType
@@ -101,6 +101,29 @@ let DefaultAvatar = ({
       </Svg>
     )
   }
+  if (type === 'labeler') {
+    return (
+      <Svg
+        testID="userAvatarFallback"
+        width={size}
+        height={size}
+        viewBox="0 0 32 32"
+        fill="none"
+        stroke="none">
+        <Path
+          d="M28 0H4C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H28C30.2091 32 32 30.2091 32 28V4C32 1.79086 30.2091 0 28 0Z"
+          fill={tokens.color.temp_purple}
+        />
+        <Path
+          d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
+          stroke="white"
+          strokeWidth="2"
+          strokeLinecap="square"
+          strokeLinejoin="round"
+        />
+      </Svg>
+    )
+  }
   return (
     <Svg
       testID="userAvatarFallback"
@@ -134,7 +157,7 @@ let UserAvatar = ({
   const backgroundColor = pal.colors.backgroundLight
 
   const aviStyle = useMemo(() => {
-    if (type === 'algo' || type === 'list') {
+    if (type === 'algo' || type === 'list' || type === 'labeler') {
       return {
         width: size,
         height: size,
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index a5ddfee8a..4fb3726cd 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -7,7 +7,7 @@ import {msg, Trans} from '@lingui/macro'
 
 import {colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
-import {useTheme as useAlfTheme} from '#/alf'
+import {useTheme as useAlfTheme, tokens} from '#/alf'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -26,10 +26,12 @@ import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/ico
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 
 export function UserBanner({
+  type,
   banner,
   moderation,
   onSelectNewBanner,
 }: {
+  type?: 'labeler' | 'default'
   banner?: string | null
   moderation?: ModerationUI
   onSelectNewBanner?: (img: RNImage | null) => void
@@ -167,7 +169,10 @@ export function UserBanner({
   ) : (
     <View
       testID="userBannerFallback"
-      style={[styles.bannerImage, styles.defaultBanner]}
+      style={[
+        styles.bannerImage,
+        type === 'labeler' ? styles.labelerBanner : styles.defaultBanner,
+      ]}
     />
   )
 }
@@ -191,4 +196,7 @@ const styles = StyleSheet.create({
   defaultBanner: {
     backgroundColor: '#0070ff',
   },
+  labelerBanner: {
+    backgroundColor: tokens.color.temp_purple,
+  },
 })
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 8fc3d9ea6..70fbb907f 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -16,7 +16,6 @@ import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
 import {useDialogControl} from '#/components/Dialog'
 import * as Prompt from '#/components/Prompt'
-import {useModalControls} from '#/state/modals'
 import {makeProfileLink} from '#/lib/routes/links'
 import {CommonNavigatorParams} from '#/lib/routes/types'
 import {getCurrentRoute} from 'lib/routes/helpers'
@@ -33,6 +32,7 @@ import {useSession} from '#/state/session'
 import {isWeb} from '#/platform/detection'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 
 import {atoms as a, useTheme as useAlf} from '#/alf'
 import * as Menu from '#/components/Menu'
@@ -45,7 +45,6 @@ import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/
 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
 import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
-import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 
 let PostDropdownBtn = ({
   testID,
@@ -55,7 +54,6 @@ let PostDropdownBtn = ({
   record,
   richText,
   style,
-  showAppealLabelItem,
   hitSlop,
 }: {
   testID: string
@@ -65,7 +63,6 @@ let PostDropdownBtn = ({
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
   style?: StyleProp<ViewStyle>
-  showAppealLabelItem?: boolean
   hitSlop?: PressableProps['hitSlop']
 }): React.ReactNode => {
   const {hasSession, currentAccount} = useSession()
@@ -73,7 +70,6 @@ let PostDropdownBtn = ({
   const alf = useAlf()
   const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
-  const {openModal} = useModalControls()
   const langPrefs = useLanguagePrefs()
   const mutedThreads = useMutedThreads()
   const toggleThreadMute = useToggleThreadMute()
@@ -83,6 +79,7 @@ let PostDropdownBtn = ({
   const openLink = useOpenLink()
   const navigation = useNavigation()
   const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+  const reportDialogControl = useReportDialogControl()
   const deletePromptControl = useDialogControl()
   const hidePromptControl = useDialogControl()
   const loggedOutWarningPromptControl = useDialogControl()
@@ -293,13 +290,7 @@ let PostDropdownBtn = ({
               <Menu.Item
                 testID="postDropdownReportBtn"
                 label={_(msg`Report post`)}
-                onPress={() => {
-                  openModal({
-                    name: 'report',
-                    uri: postUri,
-                    cid: postCid,
-                  })
-                }}>
+                onPress={() => reportDialogControl.open()}>
                 <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
                 <Menu.ItemIcon icon={Warning} position="right" />
               </Menu.Item>
@@ -314,28 +305,6 @@ let PostDropdownBtn = ({
                 <Menu.ItemIcon icon={Trash} position="right" />
               </Menu.Item>
             )}
-
-            {showAppealLabelItem && (
-              <>
-                <Menu.Divider />
-
-                <Menu.Item
-                  testID="postDropdownAppealBtn"
-                  label={_(msg`Appeal content warning`)}
-                  onPress={() => {
-                    openModal({
-                      name: 'appeal-label',
-                      uri: postUri,
-                      cid: postCid,
-                    })
-                  }}>
-                  <Menu.ItemText>
-                    {_(msg`Appeal content warning`)}
-                  </Menu.ItemText>
-                  <Menu.ItemIcon icon={CircleInfo} position="right" />
-                </Menu.Item>
-              </>
-            )}
           </Menu.Group>
         </Menu.Outer>
       </Menu.Root>
@@ -359,6 +328,15 @@ let PostDropdownBtn = ({
         confirmButtonCta={_(msg`Hide`)}
       />
 
+      <ReportDialog
+        control={reportDialogControl}
+        params={{
+          type: 'post',
+          uri: postUri,
+          cid: postCid,
+        }}
+      />
+
       <Prompt.Basic
         control={loggedOutWarningPromptControl}
         title={_(msg`Note about sharing`)}
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
deleted file mode 100644
index cd2545290..000000000
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ModerationUI, PostModeration} from '@atproto/api'
-import {Text} from '../text/Text'
-import {ShieldExclamation} from 'lib/icons'
-import {describeModerationCause} from 'lib/moderation'
-import {useLingui} from '@lingui/react'
-import {msg, Trans} from '@lingui/macro'
-import {useModalControls} from '#/state/modals'
-import {isPostMediaBlurred} from 'lib/moderation'
-
-export function ContentHider({
-  testID,
-  moderation,
-  moderationDecisions,
-  ignoreMute,
-  ignoreQuoteDecisions,
-  style,
-  childContainerStyle,
-  children,
-}: React.PropsWithChildren<{
-  testID?: string
-  moderation: ModerationUI
-  moderationDecisions?: PostModeration['decisions']
-  ignoreMute?: boolean
-  ignoreQuoteDecisions?: boolean
-  style?: StyleProp<ViewStyle>
-  childContainerStyle?: StyleProp<ViewStyle>
-}>) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const [override, setOverride] = React.useState(false)
-  const {openModal} = useModalControls()
-
-  if (
-    !moderation.blur ||
-    (ignoreMute && moderation.cause?.type === 'muted') ||
-    shouldIgnoreQuote(moderationDecisions, ignoreQuoteDecisions)
-  ) {
-    return (
-      <View testID={testID} style={[styles.outer, style]}>
-        {children}
-      </View>
-    )
-  }
-
-  const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
-  const desc = describeModerationCause(moderation.cause, 'content')
-  return (
-    <View testID={testID} style={[styles.outer, style]}>
-      <Pressable
-        onPress={() => {
-          if (!moderation.noOverride) {
-            setOverride(v => !v)
-          } else {
-            openModal({
-              name: 'moderation-details',
-              context: 'content',
-              moderation,
-            })
-          }
-        }}
-        accessibilityRole="button"
-        accessibilityHint={
-          override ? _(msg`Hide the content`) : _(msg`Show the content`)
-        }
-        accessibilityLabel=""
-        style={[
-          styles.cover,
-          moderation.noOverride
-            ? {borderWidth: 1, borderColor: pal.colors.borderDark}
-            : pal.viewLight,
-        ]}>
-        <Pressable
-          onPress={() => {
-            openModal({
-              name: 'moderation-details',
-              context: 'content',
-              moderation,
-            })
-          }}
-          accessibilityRole="button"
-          accessibilityLabel={_(msg`Learn more about this warning`)}
-          accessibilityHint="">
-          {isMute ? (
-            <FontAwesomeIcon
-              icon={['far', 'eye-slash']}
-              size={18}
-              color={pal.colors.textLight}
-            />
-          ) : (
-            <ShieldExclamation size={18} style={pal.textLight} />
-          )}
-        </Pressable>
-        <Text type="md" style={[pal.text, {flex: 1}]} numberOfLines={2}>
-          {desc.name}
-        </Text>
-        <View style={styles.showBtn}>
-          <Text type="lg" style={pal.link}>
-            {moderation.noOverride ? (
-              <Trans>Learn more</Trans>
-            ) : override ? (
-              <Trans>Hide</Trans>
-            ) : (
-              <Trans>Show</Trans>
-            )}
-          </Text>
-        </View>
-      </Pressable>
-      {override && <View style={childContainerStyle}>{children}</View>}
-    </View>
-  )
-}
-
-function shouldIgnoreQuote(
-  decisions: PostModeration['decisions'] | undefined,
-  ignore: boolean | undefined,
-): boolean {
-  if (!decisions || !ignore) {
-    return false
-  }
-  return !isPostMediaBlurred(decisions)
-}
-
-const styles = StyleSheet.create({
-  outer: {
-    overflow: 'hidden',
-  },
-  cover: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 6,
-    borderRadius: 8,
-    marginTop: 4,
-    paddingVertical: 14,
-    paddingLeft: 14,
-    paddingRight: 18,
-  },
-  showBtn: {
-    marginLeft: 'auto',
-    alignSelf: 'center',
-  },
-})
diff --git a/src/view/com/util/moderation/LabelInfo.tsx b/src/view/com/util/moderation/LabelInfo.tsx
deleted file mode 100644
index 970338752..000000000
--- a/src/view/com/util/moderation/LabelInfo.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react'
-import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {Text} from '../text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-
-export function LabelInfo({
-  details,
-  labels,
-  style,
-}: {
-  details: {did: string} | {uri: string; cid: string}
-  labels: ComAtprotoLabelDefs.Label[] | undefined
-  style?: StyleProp<ViewStyle>
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {openModal} = useModalControls()
-
-  if (!labels) {
-    return null
-  }
-  labels = labels.filter(l => !l.val.startsWith('!'))
-  if (!labels.length) {
-    return null
-  }
-
-  return (
-    <View
-      style={[
-        pal.viewLight,
-        {
-          flexDirection: 'row',
-          flexWrap: 'wrap',
-          paddingHorizontal: 12,
-          paddingVertical: 10,
-          borderRadius: 8,
-        },
-        style,
-      ]}>
-      <Text type="sm" style={pal.text}>
-        <Trans>
-          A content warning has been applied to this{' '}
-          {'did' in details ? 'account' : 'post'}.
-        </Trans>{' '}
-      </Text>
-      <Pressable
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`Appeal this decision`)}
-        accessibilityHint=""
-        onPress={() => openModal({name: 'appeal-label', ...details})}>
-        <Text type="sm" style={pal.link}>
-          <Trans>Appeal this decision.</Trans>
-        </Text>
-      </Pressable>
-    </View>
-  )
-}
diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx
deleted file mode 100644
index bc5bf9b32..000000000
--- a/src/view/com/util/moderation/PostAlerts.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-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 {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-
-export function PostAlerts({
-  moderation,
-  style,
-}: {
-  moderation: ModerationUI
-  includeMute?: boolean
-  style?: StyleProp<ViewStyle>
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {openModal} = useModalControls()
-
-  const shouldAlert = !!moderation.cause && moderation.alert
-  if (!shouldAlert) {
-    return null
-  }
-
-  const desc = describeModerationCause(moderation.cause, 'content')
-  return (
-    <Pressable
-      onPress={() => {
-        openModal({
-          name: 'moderation-details',
-          context: 'content',
-          moderation,
-        })
-      }}
-      accessibilityRole="button"
-      accessibilityLabel={_(msg`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 type="lg" style={[pal.link, styles.learnMoreBtn]}>
-          <Trans>Learn More</Trans>
-        </Text>
-      </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
deleted file mode 100644
index ede62e988..000000000
--- a/src/view/com/util/moderation/PostHider.tsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import React, {ComponentProps} from 'react'
-import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
-import {ModerationUI} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {usePalette} from 'lib/hooks/usePalette'
-import {Link} from '../Link'
-import {Text} from '../text/Text'
-import {addStyle} from 'lib/styles'
-import {describeModerationCause} from 'lib/moderation'
-import {ShieldExclamation} from 'lib/icons'
-import {useLingui} from '@lingui/react'
-import {Trans, msg} from '@lingui/macro'
-import {useModalControls} from '#/state/modals'
-
-interface Props extends ComponentProps<typeof Link> {
-  iconSize: number
-  iconStyles: StyleProp<ViewStyle>
-  moderation: ModerationUI
-}
-
-export function PostHider({
-  testID,
-  href,
-  moderation,
-  style,
-  children,
-  iconSize,
-  iconStyles,
-  ...props
-}: Props) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const [override, setOverride] = React.useState(false)
-  const {openModal} = useModalControls()
-
-  if (!moderation.blur) {
-    return (
-      <Link
-        testID={testID}
-        style={style}
-        href={href}
-        noFeedback
-        accessible={false}
-        {...props}>
-        {children}
-      </Link>
-    )
-  }
-
-  const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
-  const desc = describeModerationCause(moderation.cause, 'content')
-  return !override ? (
-    <Pressable
-      onPress={() => {
-        if (!moderation.noOverride) {
-          setOverride(v => !v)
-        }
-      }}
-      accessibilityRole="button"
-      accessibilityHint={
-        override ? _(msg`Hide the content`) : _(msg`Show the content`)
-      }
-      accessibilityLabel=""
-      style={[
-        styles.description,
-        override ? {paddingBottom: 0} : undefined,
-        pal.view,
-      ]}>
-      <Pressable
-        onPress={() => {
-          openModal({
-            name: 'moderation-details',
-            context: 'content',
-            moderation,
-          })
-        }}
-        accessibilityRole="button"
-        accessibilityLabel={_(msg`Learn more about this warning`)}
-        accessibilityHint="">
-        <View
-          style={[
-            pal.viewLight,
-            {
-              width: iconSize,
-              height: iconSize,
-              borderRadius: iconSize,
-              alignItems: 'center',
-              justifyContent: 'center',
-            },
-            iconStyles,
-          ]}>
-          {isMute ? (
-            <FontAwesomeIcon
-              icon={['far', 'eye-slash']}
-              size={14}
-              color={pal.colors.textLight}
-            />
-          ) : (
-            <ShieldExclamation size={14} style={pal.textLight} />
-          )}
-        </View>
-      </Pressable>
-      <Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}>
-        {desc.name}
-      </Text>
-      {!moderation.noOverride && (
-        <Text type="sm" style={[styles.showBtn, pal.link]}>
-          {override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
-        </Text>
-      )}
-    </Pressable>
-  ) : (
-    <Link
-      testID={testID}
-      style={addStyle(style, styles.child)}
-      href={href}
-      noFeedback>
-      {children}
-    </Link>
-  )
-}
-
-const styles = StyleSheet.create({
-  description: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    gap: 4,
-    paddingVertical: 10,
-    paddingLeft: 6,
-    paddingRight: 18,
-    marginTop: 1,
-  },
-  showBtn: {
-    marginLeft: 'auto',
-    alignSelf: 'center',
-  },
-  child: {
-    borderWidth: 0,
-    borderTopWidth: 0,
-    borderRadius: 8,
-  },
-})
diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
deleted file mode 100644
index 0f07b679b..000000000
--- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-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 {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {useModalControls} from '#/state/modals'
-
-export function ProfileHeaderAlerts({
-  moderation,
-  style,
-}: {
-  moderation: ProfileModeration
-  style?: StyleProp<ViewStyle>
-}) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {openModal} = useModalControls()
-
-  const causes = getProfileModerationCauses(moderation)
-  if (!causes.length) {
-    return null
-  }
-
-  return (
-    <View style={styles.grid}>
-      {causes.map(cause => {
-        const isMute = cause.type === 'muted'
-        const desc = describeModerationCause(cause, 'account')
-        return (
-          <Pressable
-            testID="profileHeaderAlert"
-            key={desc.name}
-            onPress={() => {
-              openModal({
-                name: 'moderation-details',
-                context: 'content',
-                moderation: {cause},
-              })
-            }}
-            accessibilityRole="button"
-            accessibilityLabel={_(msg`Learn more about this warning`)}
-            accessibilityHint=""
-            style={[styles.container, pal.viewLight, style]}>
-            {isMute ? (
-              <FontAwesomeIcon
-                icon={['far', 'eye-slash']}
-                size={14}
-                color={pal.colors.textLight}
-              />
-            ) : (
-              <ShieldExclamation style={pal.text} size={18} />
-            )}
-            <Text type="sm" style={[{flex: 1}, pal.text]}>
-              {desc.name}
-            </Text>
-            <Text type="sm" style={[pal.link, styles.learnMoreBtn]}>
-              <Trans>Learn More</Trans>
-            </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/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx
deleted file mode 100644
index 86f0cbf7b..000000000
--- a/src/view/com/util/moderation/ScreenHider.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React from 'react'
-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 {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {Text} from '../text/Text'
-import {Button} from '../forms/Button'
-import {describeModerationCause} from 'lib/moderation'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-import {s} from '#/lib/styles'
-import {CenteredView} from '../Views'
-
-export function ScreenHider({
-  testID,
-  screenDescription,
-  moderation,
-  style,
-  containerStyle,
-  children,
-}: React.PropsWithChildren<{
-  testID?: string
-  screenDescription: string
-  moderation: ModerationUI
-  style?: StyleProp<ViewStyle>
-  containerStyle?: StyleProp<ViewStyle>
-}>) {
-  const pal = usePalette('default')
-  const palInverted = usePalette('inverted')
-  const {_} = useLingui()
-  const [override, setOverride] = React.useState(false)
-  const navigation = useNavigation<NavigationProp>()
-  const {isMobile} = useWebMediaQueries()
-  const {openModal} = useModalControls()
-
-  if (!moderation.blur || override) {
-    return (
-      <View testID={testID} style={style}>
-        {children}
-      </View>
-    )
-  }
-
-  const isNoPwi =
-    moderation.cause?.type === 'label' &&
-    moderation.cause?.labelDef.id === '!no-unauthenticated'
-  const desc = describeModerationCause(moderation.cause, 'account')
-  return (
-    <CenteredView
-      style={[styles.container, pal.view, containerStyle]}
-      sideBorders>
-      <View style={styles.iconContainer}>
-        <View style={[styles.icon, palInverted.view]}>
-          <FontAwesomeIcon
-            icon={isNoPwi ? ['far', 'eye-slash'] : 'exclamation'}
-            style={pal.textInverted as FontAwesomeIconStyle}
-            size={24}
-          />
-        </View>
-      </View>
-      <Text type="title-2xl" style={[styles.title, pal.text]}>
-        {isNoPwi ? (
-          <Trans>Sign-in Required</Trans>
-        ) : (
-          <Trans>Content Warning</Trans>
-        )}
-      </Text>
-      <Text type="2xl" style={[styles.description, pal.textLight]}>
-        {isNoPwi ? (
-          <Trans>
-            This account has requested that users sign in to view their profile.
-          </Trans>
-        ) : (
-          <>
-            <Trans>This {screenDescription} has been flagged:</Trans>
-            <Text type="2xl-medium" style={[pal.text, s.ml5]}>
-              {desc.name}.
-            </Text>
-            <TouchableWithoutFeedback
-              onPress={() => {
-                openModal({
-                  name: 'moderation-details',
-                  context: 'account',
-                  moderation,
-                })
-              }}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Learn more about this warning`)}
-              accessibilityHint="">
-              <Text type="2xl" style={pal.link}>
-                <Trans>Learn More</Trans>
-              </Text>
-            </TouchableWithoutFeedback>
-          </>
-        )}{' '}
-      </Text>
-      {isMobile && <View style={styles.spacer} />}
-      <View style={styles.btnContainer}>
-        <Button
-          type="inverted"
-          onPress={() => {
-            if (navigation.canGoBack()) {
-              navigation.goBack()
-            } else {
-              navigation.navigate('Home')
-            }
-          }}
-          style={styles.btn}>
-          <Text type="button-lg" style={pal.textInverted}>
-            <Trans>Go back</Trans>
-          </Text>
-        </Button>
-        {!moderation.noOverride && (
-          <Button
-            type="default"
-            onPress={() => setOverride(v => !v)}
-            style={styles.btn}>
-            <Text type="button-lg" style={pal.text}>
-              <Trans>Show anyway</Trans>
-            </Text>
-          </Button>
-        )}
-      </View>
-    </CenteredView>
-  )
-}
-
-const styles = StyleSheet.create({
-  spacer: {
-    flex: 1,
-  },
-  container: {
-    flex: 1,
-    paddingTop: 100,
-    paddingBottom: 150,
-  },
-  iconContainer: {
-    alignItems: 'center',
-    marginBottom: 10,
-  },
-  icon: {
-    borderRadius: 25,
-    width: 50,
-    height: 50,
-    alignItems: 'center',
-    justifyContent: 'center',
-  },
-  title: {
-    textAlign: 'center',
-    marginBottom: 10,
-  },
-  description: {
-    marginBottom: 10,
-    paddingHorizontal: 20,
-    textAlign: 'center',
-  },
-  btnContainer: {
-    flexDirection: 'row',
-    justifyContent: 'center',
-    marginVertical: 10,
-    gap: 10,
-  },
-  btn: {
-    paddingHorizontal: 20,
-    paddingVertical: 14,
-  },
-})
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index c96954a11..3fa347a6d 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -41,7 +41,6 @@ let PostCtrls = ({
   post,
   record,
   richText,
-  showAppealLabelItem,
   style,
   onPressReply,
   logContext,
@@ -50,7 +49,6 @@ let PostCtrls = ({
   post: Shadow<AppBskyFeedDefs.PostView>
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
-  showAppealLabelItem?: boolean
   style?: StyleProp<ViewStyle>
   onPressReply: () => void
   logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@@ -232,7 +230,6 @@ let PostCtrls = ({
           postUri={post.uri}
           record={record}
           richText={richText}
-          showAppealLabelItem={showAppealLabelItem}
           style={styles.btnPad}
           hitSlop={big ? HITSLOP_20 : HITSLOP_10}
         />
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 35b091269..2b1c3e617 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -1,13 +1,15 @@
 import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {
+  AppBskyFeedDefs,
   AppBskyEmbedRecord,
   AppBskyFeedPost,
   AppBskyEmbedImages,
   AppBskyEmbedRecordWithMedia,
-  ModerationUI,
   AppBskyEmbedExternal,
   RichText as RichTextAPI,
+  moderatePost,
+  ModerationDecision,
 } from '@atproto/api'
 import {AtUri} from '@atproto/api'
 import {PostMeta} from '../PostMeta'
@@ -16,20 +18,20 @@ import {Text} from '../text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
 import {ComposerOptsQuote} from 'state/shell/composer'
 import {PostEmbeds} from '.'
-import {PostAlerts} from '../moderation/PostAlerts'
+import {PostAlerts} from '../../../../components/moderation/PostAlerts'
 import {makeProfileLink} from 'lib/routes/links'
 import {InfoCircleIcon} from 'lib/icons'
 import {Trans} from '@lingui/macro'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {ContentHider} from '../../../../components/moderation/ContentHider'
 import {RichText} from '#/components/RichText'
 import {atoms as a} from '#/alf'
 
 export function MaybeQuoteEmbed({
   embed,
-  moderation,
   style,
 }: {
   embed: AppBskyEmbedRecord.View
-  moderation: ModerationUI
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -39,17 +41,9 @@ export function MaybeQuoteEmbed({
     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,
-          facets: embed.record.value.facets,
-          embeds: embed.record.embeds,
-        }}
-        moderation={moderation}
+      <QuoteEmbedModerated
+        viewRecord={embed.record}
+        postRecord={embed.record.value}
         style={style}
       />
     )
@@ -75,19 +69,49 @@ export function MaybeQuoteEmbed({
   return null
 }
 
+function QuoteEmbedModerated({
+  viewRecord,
+  postRecord,
+  style,
+}: {
+  viewRecord: AppBskyEmbedRecord.ViewRecord
+  postRecord: AppBskyFeedPost.Record
+  style?: StyleProp<ViewStyle>
+}) {
+  const moderationOpts = useModerationOpts()
+  const moderation = React.useMemo(() => {
+    return moderationOpts
+      ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts)
+      : undefined
+  }, [viewRecord, moderationOpts])
+
+  const quote = {
+    author: viewRecord.author,
+    cid: viewRecord.cid,
+    uri: viewRecord.uri,
+    indexedAt: viewRecord.indexedAt,
+    text: postRecord.text,
+    facets: postRecord.facets,
+    embeds: viewRecord.embeds,
+  }
+
+  return <QuoteEmbed quote={quote} moderation={moderation} style={style} />
+}
+
 export function QuoteEmbed({
   quote,
   moderation,
   style,
 }: {
   quote: ComposerOptsQuote
-  moderation?: ModerationUI
+  moderation?: ModerationDecision
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
   const itemUrip = new AtUri(quote.uri)
   const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
   const itemTitle = `Post by ${quote.author.handle}`
+
   const richText = React.useMemo(
     () =>
       quote.text.trim()
@@ -95,6 +119,7 @@ export function QuoteEmbed({
         : undefined,
     [quote.text, quote.facets],
   )
+
   const embed = React.useMemo(() => {
     const e = quote.embeds?.[0]
 
@@ -108,40 +133,52 @@ export function QuoteEmbed({
       return e.media
     }
   }, [quote.embeds])
+
   return (
-    <Link
-      style={[styles.container, pal.borderDark, style]}
-      hoverStyle={{borderColor: pal.colors.borderLinkHover}}
-      href={itemHref}
-      title={itemTitle}>
-      <View pointerEvents="none">
-        <PostMeta
-          author={quote.author}
-          showAvatar
-          authorHasWarning={false}
-          postHref={itemHref}
-          timestamp={quote.indexedAt}
-        />
-      </View>
-      {moderation ? (
-        <PostAlerts moderation={moderation} style={styles.alert} />
-      ) : null}
-      {richText ? (
-        <RichText
-          enableTags
-          value={richText}
-          style={[a.text_md]}
-          numberOfLines={20}
-          disableLinks
-          authorHandle={quote.author.handle}
-        />
-      ) : null}
-      {embed && <PostEmbeds embed={embed} moderation={{}} />}
-    </Link>
+    <ContentHider modui={moderation?.ui('contentList')}>
+      <Link
+        style={[styles.container, pal.borderDark, style]}
+        hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+        href={itemHref}
+        title={itemTitle}>
+        <View pointerEvents="none">
+          <PostMeta
+            author={quote.author}
+            moderation={moderation}
+            showAvatar
+            authorHasWarning={false}
+            postHref={itemHref}
+            timestamp={quote.indexedAt}
+          />
+        </View>
+        {moderation ? (
+          <PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
+        ) : null}
+        {richText ? (
+          <RichText
+            value={richText}
+            style={[a.text_md]}
+            numberOfLines={20}
+            disableLinks
+          />
+        ) : null}
+        {embed && <PostEmbeds embed={embed} moderation={moderation} />}
+      </Link>
+    </ContentHider>
   )
 }
 
-export default QuoteEmbed
+function viewRecordToPostView(
+  viewRecord: AppBskyEmbedRecord.ViewRecord,
+): AppBskyFeedDefs.PostView {
+  const {value, embeds, ...rest} = viewRecord
+  return {
+    ...rest,
+    $type: 'app.bsky.feed.defs#postView',
+    record: value,
+    embed: embeds?.[0],
+  }
+}
 
 const styles = StyleSheet.create({
   container: {
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 7e235babb..47091fbb0 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -15,8 +15,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyFeedDefs,
   AppBskyGraphDefs,
-  ModerationUI,
-  PostModeration,
+  ModerationDecision,
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -26,9 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {MaybeQuoteEmbed} from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ListEmbed} from './ListEmbed'
-import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {ContentHider} from '../moderation/ContentHider'
+import {ContentHider} from '../../../../components/moderation/ContentHider'
 import {isNative} from '#/platform/detection'
 import {shareUrl} from '#/lib/sharing'
 
@@ -42,12 +40,10 @@ type Embed =
 export function PostEmbeds({
   embed,
   moderation,
-  moderationDecisions,
   style,
 }: {
   embed?: Embed
-  moderation: ModerationUI
-  moderationDecisions?: PostModeration['decisions']
+  moderation?: ModerationDecision
   style?: StyleProp<ViewStyle>
 }) {
   const pal = usePalette('default')
@@ -66,18 +62,10 @@ export function PostEmbeds({
   // quote post with media
   // =
   if (AppBskyEmbedRecordWithMedia.isView(embed)) {
-    const isModOnQuote =
-      (AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
-        isCauseALabelOnUri(moderation.cause, embed.record.record.uri)) ||
-      (moderationDecisions && isQuoteBlurred(moderationDecisions))
-    const mediaModeration = isModOnQuote ? {} : moderation
-    const quoteModeration = isModOnQuote ? moderation : {}
     return (
       <View style={style}>
-        <PostEmbeds embed={embed.media} moderation={mediaModeration} />
-        <ContentHider moderation={quoteModeration}>
-          <MaybeQuoteEmbed embed={embed.record} moderation={quoteModeration} />
-        </ContentHider>
+        <PostEmbeds embed={embed.media} moderation={moderation} />
+        <MaybeQuoteEmbed embed={embed.record} />
       </View>
     )
   }
@@ -86,6 +74,7 @@ export function PostEmbeds({
     // custom feed embed (i.e. generator view)
     // =
     if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
+      // TODO moderation
       return (
         <FeedSourceCard
           feedUri={embed.record.uri}
@@ -97,16 +86,13 @@ export function PostEmbeds({
 
     // list embed
     if (AppBskyGraphDefs.isListView(embed.record)) {
+      // TODO moderation
       return <ListEmbed item={embed.record} />
     }
 
     // quote post
     // =
-    return (
-      <ContentHider moderation={moderation}>
-        <MaybeQuoteEmbed embed={embed} style={style} moderation={moderation} />
-      </ContentHider>
-    )
+    return <MaybeQuoteEmbed embed={embed} style={style} />
   }
 
   // image embed
@@ -132,35 +118,41 @@ export function PostEmbeds({
       if (images.length === 1) {
         const {alt, thumb, aspectRatio} = images[0]
         return (
-          <View style={[styles.imagesContainer, style]}>
-            <AutoSizedImage
-              alt={alt}
-              uri={thumb}
-              dimensionsHint={aspectRatio}
-              onPress={() => _openLightbox(0)}
-              onPressIn={() => onPressIn(0)}
-              style={[styles.singleImage]}>
-              {alt === '' ? null : (
-                <View style={styles.altContainer}>
-                  <Text style={styles.alt} accessible={false}>
-                    ALT
-                  </Text>
-                </View>
-              )}
-            </AutoSizedImage>
-          </View>
+          <ContentHider modui={moderation?.ui('contentMedia')}>
+            <View style={[styles.imagesContainer, style]}>
+              <AutoSizedImage
+                alt={alt}
+                uri={thumb}
+                dimensionsHint={aspectRatio}
+                onPress={() => _openLightbox(0)}
+                onPressIn={() => onPressIn(0)}
+                style={[styles.singleImage]}>
+                {alt === '' ? null : (
+                  <View style={styles.altContainer}>
+                    <Text style={styles.alt} accessible={false}>
+                      ALT
+                    </Text>
+                  </View>
+                )}
+              </AutoSizedImage>
+            </View>
+          </ContentHider>
         )
       }
 
       return (
-        <View style={[styles.imagesContainer, style]}>
-          <ImageLayoutGrid
-            images={embed.images}
-            onPress={_openLightbox}
-            onPressIn={onPressIn}
-            style={embed.images.length === 1 ? [styles.singleImage] : undefined}
-          />
-        </View>
+        <ContentHider modui={moderation?.ui('contentMedia')}>
+          <View style={[styles.imagesContainer, style]}>
+            <ImageLayoutGrid
+              images={embed.images}
+              onPress={_openLightbox}
+              onPressIn={onPressIn}
+              style={
+                embed.images.length === 1 ? [styles.singleImage] : undefined
+              }
+            />
+          </View>
+        </ContentHider>
       )
     }
   }
@@ -171,15 +163,17 @@ export function PostEmbeds({
     const link = embed.external
 
     return (
-      <Link
-        asAnchor
-        anchorNoUnderline
-        href={link.uri}
-        style={[styles.extOuter, pal.view, pal.borderDark, style]}
-        hoverStyle={{borderColor: pal.colors.borderLinkHover}}
-        onLongPress={onShareExternal}>
-        <ExternalLinkEmbed link={link} />
-      </Link>
+      <ContentHider modui={moderation?.ui('contentMedia')}>
+        <Link
+          asAnchor
+          anchorNoUnderline
+          href={link.uri}
+          style={[styles.extOuter, pal.view, pal.borderDark, style]}
+          hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+          onLongPress={onShareExternal}>
+          <ExternalLinkEmbed link={link} />
+        </Link>
+      </ContentHider>
     )
   }
 
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
new file mode 100644
index 000000000..64f2376a4
--- /dev/null
+++ b/src/view/screens/DebugMod.tsx
@@ -0,0 +1,923 @@
+import React from 'react'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {View} from 'react-native'
+import {
+  LABELS,
+  mock,
+  moderatePost,
+  moderateProfile,
+  ModerationOpts,
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  LabelPreference,
+  ModerationDecision,
+  ModerationBehavior,
+  RichText,
+  ComAtprotoLabelDefs,
+  interpretLabelValueDefinition,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {moderationOptsOverrideContext} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {FeedNotification} from '#/state/queries/notifications/types'
+import {
+  groupNotifications,
+  shouldFilterNotif,
+} from '#/state/queries/notifications/util'
+
+import {atoms as a, useTheme} from '#/alf'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {H1, H3, P, Text} from '#/components/Typography'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
+import * as Toggle from '#/components/forms/Toggle'
+import * as ToggleButton from '#/components/forms/ToggleButton'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {
+  ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom,
+  ChevronTop_Stroke2_Corner0_Rounded as ChevronTop,
+} from '#/components/icons/Chevron'
+import {ScreenHider} from '../../components/moderation/ScreenHider'
+import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard'
+import {ProfileCard} from '../com/profile/ProfileCard'
+import {FeedItem} from '../com/posts/FeedItem'
+import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem'
+import {PostThreadItem} from '../com/post-thread/PostThreadItem'
+import {Divider} from '#/components/Divider'
+
+const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys(
+  LABELS,
+) as (keyof typeof LABELS)[]
+
+export const DebugModScreen = ({}: NativeStackScreenProps<
+  CommonNavigatorParams,
+  'DebugMod'
+>) => {
+  const t = useTheme()
+  const [scenario, setScenario] = React.useState<string[]>(['label'])
+  const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([])
+  const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]])
+  const [target, setTarget] = React.useState<string[]>(['account'])
+  const [visibility, setVisiblity] = React.useState<string[]>(['warn'])
+  const [customLabelDef, setCustomLabelDef] =
+    React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({
+      identifier: 'custom',
+      blurs: 'content',
+      severity: 'alert',
+      defaultSetting: 'warn',
+      locales: [
+        {
+          lang: 'en',
+          name: 'Custom label',
+          description: 'A custom label created in this test environment',
+        },
+      ],
+    })
+  const [view, setView] = React.useState<string[]>(['post'])
+  const labelStrings = useGlobalLabelStrings()
+  const {currentAccount} = useSession()
+
+  const isTargetMe =
+    scenario[0] === 'label' && scenarioSwitches.includes('targetMe')
+  const isSelfLabel =
+    scenario[0] === 'label' && scenarioSwitches.includes('selfLabel')
+  const noAdult =
+    scenario[0] === 'label' && scenarioSwitches.includes('noAdult')
+  const isLoggedOut =
+    scenario[0] === 'label' && scenarioSwitches.includes('loggedOut')
+  const isFollowing = scenarioSwitches.includes('following')
+
+  const did =
+    isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test'
+
+  const profile = React.useMemo(() => {
+    const mockedProfile = mock.profileViewBasic({
+      handle: `bob.test`,
+      displayName: 'Bob Robertson',
+      description: 'User with this as their bio',
+      labels:
+        scenario[0] === 'label' && target[0] === 'account'
+          ? [
+              mock.label({
+                src: isSelfLabel ? did : undefined,
+                val: label[0],
+                uri: `at://${did}/`,
+              }),
+            ]
+          : scenario[0] === 'label' && target[0] === 'profile'
+          ? [
+              mock.label({
+                src: isSelfLabel ? did : undefined,
+                val: label[0],
+                uri: `at://${did}/app.bsky.actor.profile/self`,
+              }),
+            ]
+          : undefined,
+      viewer: mock.actorViewerState({
+        following: isFollowing
+          ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234`
+          : undefined,
+        muted: scenario[0] === 'mute',
+        mutedByList: undefined,
+        blockedBy: undefined,
+        blocking:
+          scenario[0] === 'block'
+            ? `at://did:web:alice.test/app.bsky.actor.block/fake`
+            : undefined,
+        blockingByList: undefined,
+      }),
+    })
+    mockedProfile.did = did
+    mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png'
+    mockedProfile.banner =
+      'https://bsky.social/about/images/social-card-default-gradient.png'
+    return mockedProfile
+  }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount])
+
+  const post = React.useMemo(() => {
+    return mock.postView({
+      record: mock.post({
+        text: "This is the body of the post. It's where the text goes. You get the idea.",
+      }),
+      author: profile,
+      labels:
+        scenario[0] === 'label' && target[0] === 'post'
+          ? [
+              mock.label({
+                src: isSelfLabel ? did : undefined,
+                val: label[0],
+                uri: `at://${did}/app.bsky.feed.post/fake`,
+              }),
+            ]
+          : undefined,
+      embed:
+        target[0] === 'embed'
+          ? mock.embedRecordView({
+              record: mock.post({
+                text: 'Embed',
+              }),
+              labels:
+                scenario[0] === 'label' && target[0] === 'embed'
+                  ? [
+                      mock.label({
+                        src: isSelfLabel ? did : undefined,
+                        val: label[0],
+                        uri: `at://${did}/app.bsky.feed.post/fake`,
+                      }),
+                    ]
+                  : undefined,
+              author: profile,
+            })
+          : {
+              $type: 'app.bsky.embed.images#view',
+              images: [
+                {
+                  thumb:
+                    'https://bsky.social/about/images/social-card-default-gradient.png',
+                  fullsize:
+                    'https://bsky.social/about/images/social-card-default-gradient.png',
+                  alt: '',
+                },
+              ],
+            },
+    })
+  }, [scenario, label, target, profile, isSelfLabel, did])
+
+  const replyNotif = React.useMemo(() => {
+    const notif = mock.replyNotification({
+      record: mock.post({
+        text: "This is the body of the post. It's where the text goes. You get the idea.",
+        reply: {
+          parent: {
+            uri: `at://${did}/app.bsky.feed.post/fake-parent`,
+            cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
+          },
+          root: {
+            uri: `at://${did}/app.bsky.feed.post/fake-parent`,
+            cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
+          },
+        },
+      }),
+      author: profile,
+      labels:
+        scenario[0] === 'label' && target[0] === 'post'
+          ? [
+              mock.label({
+                src: isSelfLabel ? did : undefined,
+                val: label[0],
+                uri: `at://${did}/app.bsky.feed.post/fake`,
+              }),
+            ]
+          : undefined,
+    })
+    const [item] = groupNotifications([notif])
+    item.subject = mock.postView({
+      record: notif.record as AppBskyFeedPost.Record,
+      author: profile,
+      labels: notif.labels,
+    })
+    return item
+  }, [scenario, label, target, profile, isSelfLabel, did])
+
+  const followNotif = React.useMemo(() => {
+    const notif = mock.followNotification({
+      author: profile,
+      subjectDid: currentAccount?.did || '',
+    })
+    const [item] = groupNotifications([notif])
+    return item
+  }, [profile, currentAccount])
+
+  const modOpts = React.useMemo(() => {
+    return {
+      userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test',
+      prefs: {
+        adultContentEnabled: !noAdult,
+        labels: {
+          [label[0]]: visibility[0] as LabelPreference,
+        },
+        labelers: [
+          {
+            did: 'did:plc:fake-labeler',
+            labels: {[label[0]]: visibility[0] as LabelPreference},
+          },
+        ],
+        mutedWords: [],
+        hiddenPosts: [],
+      },
+      labelDefs: {
+        'did:plc:fake-labeler': [
+          interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'),
+        ],
+      },
+    }
+  }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef])
+
+  const profileModeration = React.useMemo(() => {
+    return moderateProfile(profile, modOpts)
+  }, [profile, modOpts])
+  const postModeration = React.useMemo(() => {
+    return moderatePost(post, modOpts)
+  }, [post, modOpts])
+
+  return (
+    <moderationOptsOverrideContext.Provider value={modOpts}>
+      <ScrollView>
+        <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}>
+          <H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1>
+
+          <Heading title="" subtitle="Scenario" />
+          <ToggleButton.Group
+            label="Scenario"
+            values={scenario}
+            onChange={setScenario}>
+            <ToggleButton.Button name="label" label="Label">
+              Label
+            </ToggleButton.Button>
+            <ToggleButton.Button name="block" label="Block">
+              Block
+            </ToggleButton.Button>
+            <ToggleButton.Button name="mute" label="Mute">
+              Mute
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+
+          {scenario[0] === 'label' && (
+            <>
+              <View
+                style={[
+                  a.border,
+                  a.rounded_sm,
+                  a.mt_lg,
+                  a.mb_lg,
+                  a.p_lg,
+                  t.atoms.border_contrast_medium,
+                ]}>
+                <Toggle.Group
+                  label="Toggle"
+                  type="radio"
+                  values={label}
+                  onChange={setLabel}>
+                  <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+                    {LABEL_VALUES.map(labelValue => {
+                      let targetFixed = target[0]
+                      if (
+                        targetFixed !== 'account' &&
+                        targetFixed !== 'profile'
+                      ) {
+                        targetFixed = 'content'
+                      }
+                      const disabled =
+                        isSelfLabel &&
+                        LABELS[labelValue].flags.includes('no-self')
+                      return (
+                        <Toggle.Item
+                          key={labelValue}
+                          name={labelValue}
+                          label={labelStrings[labelValue].name}
+                          disabled={disabled}
+                          style={disabled ? {opacity: 0.5} : undefined}>
+                          <Toggle.Radio />
+                          <Toggle.Label>{labelValue}</Toggle.Label>
+                        </Toggle.Item>
+                      )
+                    })}
+                    <Toggle.Item
+                      name="custom"
+                      label="Custom label"
+                      disabled={isSelfLabel}
+                      style={isSelfLabel ? {opacity: 0.5} : undefined}>
+                      <Toggle.Radio />
+                      <Toggle.Label>Custom label</Toggle.Label>
+                    </Toggle.Item>
+                  </View>
+                </Toggle.Group>
+
+                {label[0] === 'custom' ? (
+                  <CustomLabelForm
+                    def={customLabelDef}
+                    setDef={setCustomLabelDef}
+                  />
+                ) : (
+                  <>
+                    <View style={{height: 10}} />
+                    <Divider />
+                  </>
+                )}
+
+                <View style={{height: 10}} />
+
+                <SmallToggler label="Advanced">
+                  <Toggle.Group
+                    label="Toggle"
+                    type="checkbox"
+                    values={scenarioSwitches}
+                    onChange={setScenarioSwitches}>
+                    <View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}>
+                      <Toggle.Item name="targetMe" label="Target is me">
+                        <Toggle.Checkbox />
+                        <Toggle.Label>Target is me</Toggle.Label>
+                      </Toggle.Item>
+                      <Toggle.Item name="following" label="Following target">
+                        <Toggle.Checkbox />
+                        <Toggle.Label>Following target</Toggle.Label>
+                      </Toggle.Item>
+                      <Toggle.Item name="selfLabel" label="Self label">
+                        <Toggle.Checkbox />
+                        <Toggle.Label>Self label</Toggle.Label>
+                      </Toggle.Item>
+                      <Toggle.Item name="noAdult" label="Adult disabled">
+                        <Toggle.Checkbox />
+                        <Toggle.Label>Adult disabled</Toggle.Label>
+                      </Toggle.Item>
+                      <Toggle.Item name="loggedOut" label="Logged out">
+                        <Toggle.Checkbox />
+                        <Toggle.Label>Logged out</Toggle.Label>
+                      </Toggle.Item>
+                    </View>
+                  </Toggle.Group>
+
+                  {LABELS[label[0] as keyof typeof LABELS]?.configurable !==
+                    false && (
+                    <View style={[a.mt_md]}>
+                      <Text
+                        style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}>
+                        Preference
+                      </Text>
+                      <Toggle.Group
+                        label="Preference"
+                        type="radio"
+                        values={visibility}
+                        onChange={setVisiblity}>
+                        <View
+                          style={[
+                            a.flex_row,
+                            a.gap_md,
+                            a.flex_wrap,
+                            a.align_center,
+                          ]}>
+                          <Toggle.Item name="hide" label="Hide">
+                            <Toggle.Radio />
+                            <Toggle.Label>Hide</Toggle.Label>
+                          </Toggle.Item>
+                          <Toggle.Item name="warn" label="Warn">
+                            <Toggle.Radio />
+                            <Toggle.Label>Warn</Toggle.Label>
+                          </Toggle.Item>
+                          <Toggle.Item name="ignore" label="Ignore">
+                            <Toggle.Radio />
+                            <Toggle.Label>Ignore</Toggle.Label>
+                          </Toggle.Item>
+                        </View>
+                      </Toggle.Group>
+                    </View>
+                  )}
+                </SmallToggler>
+              </View>
+
+              <View style={[a.flex_row, a.flex_wrap, a.gap_md]}>
+                <View>
+                  <Text
+                    style={[
+                      a.font_bold,
+                      a.text_xs,
+                      t.atoms.text,
+                      a.pl_md,
+                      a.pb_xs,
+                    ]}>
+                    Target
+                  </Text>
+                  <View
+                    style={[
+                      a.border,
+                      a.rounded_full,
+                      a.px_md,
+                      a.py_sm,
+                      t.atoms.border_contrast_medium,
+                      t.atoms.bg,
+                    ]}>
+                    <Toggle.Group
+                      label="Target"
+                      type="radio"
+                      values={target}
+                      onChange={setTarget}>
+                      <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+                        <Toggle.Item name="account" label="Account">
+                          <Toggle.Radio />
+                          <Toggle.Label>Account</Toggle.Label>
+                        </Toggle.Item>
+                        <Toggle.Item name="profile" label="Profile">
+                          <Toggle.Radio />
+                          <Toggle.Label>Profile</Toggle.Label>
+                        </Toggle.Item>
+                        <Toggle.Item name="post" label="Post">
+                          <Toggle.Radio />
+                          <Toggle.Label>Post</Toggle.Label>
+                        </Toggle.Item>
+                        <Toggle.Item name="embed" label="Embed">
+                          <Toggle.Radio />
+                          <Toggle.Label>Embed</Toggle.Label>
+                        </Toggle.Item>
+                      </View>
+                    </Toggle.Group>
+                  </View>
+                </View>
+              </View>
+            </>
+          )}
+
+          <Spacer />
+
+          <Heading title="" subtitle="Results" />
+
+          <ToggleButton.Group label="Results" values={view} onChange={setView}>
+            <ToggleButton.Button name="post" label="Post">
+              Post
+            </ToggleButton.Button>
+            <ToggleButton.Button name="notifications" label="Notifications">
+              Notifications
+            </ToggleButton.Button>
+            <ToggleButton.Button name="account" label="Account">
+              Account
+            </ToggleButton.Button>
+            <ToggleButton.Button name="data" label="Data">
+              Data
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+
+          <View
+            style={[
+              a.border,
+              a.rounded_sm,
+              a.mt_lg,
+              a.p_md,
+              t.atoms.border_contrast_medium,
+            ]}>
+            {view[0] === 'post' && (
+              <>
+                <Heading title="Post" subtitle="in feed" />
+                <MockPostFeedItem post={post} moderation={postModeration} />
+
+                <Heading title="Post" subtitle="viewed directly" />
+                <MockPostThreadItem post={post} moderation={postModeration} />
+
+                <Heading title="Post" subtitle="reply in thread" />
+                <MockPostThreadItem
+                  post={post}
+                  moderation={postModeration}
+                  reply
+                />
+              </>
+            )}
+
+            {view[0] === 'notifications' && (
+              <>
+                <Heading title="Notification" subtitle="quote or reply" />
+                <MockNotifItem notif={replyNotif} moderationOpts={modOpts} />
+                <View style={{height: 20}} />
+                <Heading title="Notification" subtitle="follow or like" />
+                <MockNotifItem notif={followNotif} moderationOpts={modOpts} />
+              </>
+            )}
+
+            {view[0] === 'account' && (
+              <>
+                <Heading title="Account" subtitle="in listing" />
+                <MockAccountCard
+                  profile={profile}
+                  moderation={profileModeration}
+                />
+
+                <Heading title="Account" subtitle="viewing directly" />
+                <MockAccountScreen
+                  profile={profile}
+                  moderation={profileModeration}
+                  moderationOpts={modOpts}
+                />
+              </>
+            )}
+
+            {view[0] === 'data' && (
+              <>
+                <ModerationUIView
+                  label="Profile Moderation UI"
+                  mod={profileModeration}
+                />
+                <ModerationUIView
+                  label="Post Moderation UI"
+                  mod={postModeration}
+                />
+                <DataView
+                  label={label[0]}
+                  data={LABELS[label[0] as keyof typeof LABELS]}
+                />
+                <DataView
+                  label="Profile Moderation Data"
+                  data={profileModeration}
+                />
+                <DataView label="Post Moderation Data" data={postModeration} />
+              </>
+            )}
+          </View>
+
+          <View style={{height: 400}} />
+        </CenteredView>
+      </ScrollView>
+    </moderationOptsOverrideContext.Provider>
+  )
+}
+
+function Heading({title, subtitle}: {title: string; subtitle?: string}) {
+  const t = useTheme()
+  return (
+    <H3 style={[a.text_3xl, a.font_bold, a.pb_md]}>
+      {title}{' '}
+      {!!subtitle && (
+        <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3>
+      )}
+    </H3>
+  )
+}
+
+function CustomLabelForm({
+  def,
+  setDef,
+}: {
+  def: ComAtprotoLabelDefs.LabelValueDefinition
+  setDef: React.Dispatch<
+    React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition>
+  >
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.flex_wrap,
+        a.gap_md,
+        t.atoms.bg_contrast_25,
+        a.rounded_md,
+        a.p_md,
+        a.mt_md,
+      ]}>
+      <View>
+        <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
+          Blurs
+        </Text>
+        <View
+          style={[
+            a.border,
+            a.rounded_full,
+            a.px_md,
+            a.py_sm,
+            t.atoms.border_contrast_medium,
+            t.atoms.bg,
+          ]}>
+          <Toggle.Group
+            label="Blurs"
+            type="radio"
+            values={[def.blurs]}
+            onChange={values => setDef(v => ({...v, blurs: values[0]}))}>
+            <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+              <Toggle.Item name="content" label="Content">
+                <Toggle.Radio />
+                <Toggle.Label>Content</Toggle.Label>
+              </Toggle.Item>
+              <Toggle.Item name="media" label="Media">
+                <Toggle.Radio />
+                <Toggle.Label>Media</Toggle.Label>
+              </Toggle.Item>
+              <Toggle.Item name="none" label="None">
+                <Toggle.Radio />
+                <Toggle.Label>None</Toggle.Label>
+              </Toggle.Item>
+            </View>
+          </Toggle.Group>
+        </View>
+      </View>
+      <View>
+        <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
+          Severity
+        </Text>
+        <View
+          style={[
+            a.border,
+            a.rounded_full,
+            a.px_md,
+            a.py_sm,
+            t.atoms.border_contrast_medium,
+            t.atoms.bg,
+          ]}>
+          <Toggle.Group
+            label="Severity"
+            type="radio"
+            values={[def.severity]}
+            onChange={values => setDef(v => ({...v, severity: values[0]}))}>
+            <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}>
+              <Toggle.Item name="alert" label="Alert">
+                <Toggle.Radio />
+                <Toggle.Label>Alert</Toggle.Label>
+              </Toggle.Item>
+              <Toggle.Item name="inform" label="Inform">
+                <Toggle.Radio />
+                <Toggle.Label>Inform</Toggle.Label>
+              </Toggle.Item>
+              <Toggle.Item name="none" label="None">
+                <Toggle.Radio />
+                <Toggle.Label>None</Toggle.Label>
+              </Toggle.Item>
+            </View>
+          </Toggle.Group>
+        </View>
+      </View>
+    </View>
+  )
+}
+
+function Toggler({label, children}: React.PropsWithChildren<{label: string}>) {
+  const t = useTheme()
+  const [show, setShow] = React.useState(false)
+  return (
+    <View style={a.mb_md}>
+      <View
+        style={[
+          t.atoms.border_contrast_medium,
+          a.border,
+          a.rounded_sm,
+          a.p_xs,
+        ]}>
+        <Button
+          variant="solid"
+          color="secondary"
+          label="Toggle visibility"
+          size="small"
+          onPress={() => setShow(!show)}>
+          <ButtonText>{label}</ButtonText>
+          <ButtonIcon
+            icon={show ? ChevronTop : ChevronBottom}
+            position="right"
+          />
+        </Button>
+        {show && children}
+      </View>
+    </View>
+  )
+}
+
+function SmallToggler({
+  label,
+  children,
+}: React.PropsWithChildren<{label: string}>) {
+  const [show, setShow] = React.useState(false)
+  return (
+    <View>
+      <View style={[a.flex_row]}>
+        <Button
+          variant="ghost"
+          color="secondary"
+          label="Toggle visibility"
+          size="tiny"
+          onPress={() => setShow(!show)}>
+          <ButtonText>{label}</ButtonText>
+          <ButtonIcon
+            icon={show ? ChevronTop : ChevronBottom}
+            position="right"
+          />
+        </Button>
+      </View>
+      {show && children}
+    </View>
+  )
+}
+
+function DataView({label, data}: {label: string; data: any}) {
+  return (
+    <Toggler label={label}>
+      <Text style={[{fontFamily: 'monospace'}, a.p_md]}>
+        {JSON.stringify(data, null, 2)}
+      </Text>
+    </Toggler>
+  )
+}
+
+function ModerationUIView({
+  mod,
+  label,
+}: {
+  mod: ModerationDecision
+  label: string
+}) {
+  return (
+    <Toggler label={label}>
+      <View style={a.p_lg}>
+        {[
+          'profileList',
+          'profileView',
+          'avatar',
+          'banner',
+          'displayName',
+          'contentList',
+          'contentView',
+          'contentMedia',
+        ].map(key => {
+          const ui = mod.ui(key as keyof ModerationBehavior)
+          return (
+            <View key={key} style={[a.flex_row, a.gap_md]}>
+              <Text style={[a.font_bold, {width: 100}]}>{key}</Text>
+              <Flag v={ui.filter} label="Filter" />
+              <Flag v={ui.blur} label="Blur" />
+              <Flag v={ui.alert} label="Alert" />
+              <Flag v={ui.inform} label="Inform" />
+              <Flag v={ui.noOverride} label="No-override" />
+            </View>
+          )
+        })}
+      </View>
+    </Toggler>
+  )
+}
+
+function Spacer() {
+  return <View style={{height: 30}} />
+}
+
+function MockPostFeedItem({
+  post,
+  moderation,
+}: {
+  post: AppBskyFeedDefs.PostView
+  moderation: ModerationDecision
+}) {
+  const t = useTheme()
+  if (moderation.ui('contentList').filter) {
+    return (
+      <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
+        Filtered from the feed
+      </P>
+    )
+  }
+  return (
+    <FeedItem
+      post={post}
+      record={post.record as AppBskyFeedPost.Record}
+      moderation={moderation}
+      reason={undefined}
+    />
+  )
+}
+
+function MockPostThreadItem({
+  post,
+  reply,
+}: {
+  post: AppBskyFeedDefs.PostView
+  moderation: ModerationDecision
+  reply?: boolean
+}) {
+  return (
+    <PostThreadItem
+      // @ts-ignore
+      post={post}
+      record={post.record as AppBskyFeedPost.Record}
+      depth={reply ? 1 : 0}
+      isHighlightedPost={!reply}
+      treeView={false}
+      prevPost={undefined}
+      nextPost={undefined}
+      hasPrecedingItem={false}
+      onPostReply={() => {}}
+    />
+  )
+}
+
+function MockNotifItem({
+  notif,
+  moderationOpts,
+}: {
+  notif: FeedNotification
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  if (shouldFilterNotif(notif.notification, moderationOpts)) {
+    return (
+      <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}>
+        Filtered from the feed
+      </P>
+    )
+  }
+  return <NotifFeedItem item={notif} moderationOpts={moderationOpts} />
+}
+
+function MockAccountCard({
+  profile,
+  moderation,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderation: ModerationDecision
+}) {
+  const t = useTheme()
+
+  if (moderation.ui('profileList').filter) {
+    return (
+      <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
+        Filtered from the listing
+      </P>
+    )
+  }
+
+  return <ProfileCard profile={profile} />
+}
+
+function MockAccountScreen({
+  profile,
+  moderation,
+  moderationOpts,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderation: ModerationDecision
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  return (
+    <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}>
+      <ScreenHider
+        style={{}}
+        screenDescription={_(msg`profile`)}
+        modui={moderation.ui('profileView')}>
+        <ProfileHeaderStandard
+          // @ts-ignore ProfileViewBasic is close enough -prf
+          profile={profile}
+          moderationOpts={moderationOpts}
+          descriptionRT={new RichText({text: profile.description as string})}
+        />
+      </ScreenHider>
+    </View>
+  )
+}
+
+function Flag({v, label}: {v: boolean | undefined; label: string}) {
+  const t = useTheme()
+  return (
+    <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+      <View
+        style={[
+          a.justify_center,
+          a.align_center,
+          a.rounded_xs,
+          a.border,
+          t.atoms.border_contrast_medium,
+          {
+            backgroundColor: t.palette.contrast_25,
+            width: 14,
+            height: 14,
+          },
+        ]}>
+        {v && <Check size="xs" fill={t.palette.contrast_900} />}
+      </View>
+      <P style={a.text_xs}>{label}</P>
+    </View>
+  )
+}
diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx
deleted file mode 100644
index 928766c30..000000000
--- a/src/view/screens/Moderation.tsx
+++ /dev/null
@@ -1,304 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import {ComAtprotoLabelDefs} from '@atproto/api'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {s} from 'lib/styles'
-import {CenteredView} from '../com/util/Views'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {Link, TextLink} from '../com/util/Link'
-import {Text} from '../com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {useModalControls} from '#/state/modals'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {ToggleButton} from '../com/util/forms/ToggleButton'
-import {useSession} from '#/state/session'
-import {
-  useProfileQuery,
-  useProfileUpdateMutation,
-} from '#/state/queries/profile'
-import {ScrollView} from '../com/util/Views'
-import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
-
-type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
-export function ModerationScreen({}: Props) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const setMinimalShellMode = useSetMinimalShellMode()
-  const {screen, track} = useAnalytics()
-  const {isTabletOrDesktop} = useWebMediaQueries()
-  const {openModal} = useModalControls()
-  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
-
-  useFocusEffect(
-    React.useCallback(() => {
-      screen('Moderation')
-      setMinimalShellMode(false)
-    }, [screen, setMinimalShellMode]),
-  )
-
-  const onPressContentFiltering = React.useCallback(() => {
-    track('Moderation:ContentfilteringButtonClicked')
-    openModal({name: 'content-filtering-settings'})
-  }, [track, openModal])
-
-  return (
-    <CenteredView
-      style={[
-        s.hContentRegion,
-        pal.border,
-        isTabletOrDesktop ? styles.desktopContainer : pal.viewLight,
-      ]}
-      testID="moderationScreen">
-      <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
-      <ScrollView contentContainerStyle={[styles.noBorder]}>
-        <View style={styles.spacer} />
-        <TouchableOpacity
-          testID="contentFilteringBtn"
-          style={[styles.linkCard, pal.view]}
-          onPress={onPressContentFiltering}
-          accessibilityRole="tab"
-          accessibilityHint=""
-          accessibilityLabel={_(msg`Open content filtering settings`)}>
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="eye"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            <Trans>Content filtering</Trans>
-          </Text>
-        </TouchableOpacity>
-        <TouchableOpacity
-          testID="mutedWordsBtn"
-          style={[styles.linkCard, pal.view]}
-          onPress={() => mutedWordsDialogControl.open()}
-          accessibilityRole="tab"
-          accessibilityHint=""
-          accessibilityLabel={_(msg`Open muted words settings`)}>
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="filter"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            <Trans>Muted words & tags</Trans>
-          </Text>
-        </TouchableOpacity>
-        <Link
-          testID="moderationlistsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/modlists">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="users-slash"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            <Trans>Moderation lists</Trans>
-          </Text>
-        </Link>
-        <Link
-          testID="mutedAccountsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/muted-accounts">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="user-slash"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            <Trans>Muted accounts</Trans>
-          </Text>
-        </Link>
-        <Link
-          testID="blockedAccountsBtn"
-          style={[styles.linkCard, pal.view]}
-          href="/moderation/blocked-accounts">
-          <View style={[styles.iconContainer, pal.btn]}>
-            <FontAwesomeIcon
-              icon="ban"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-          </View>
-          <Text type="lg" style={pal.text}>
-            <Trans>Blocked accounts</Trans>
-          </Text>
-        </Link>
-        <Text
-          type="xl-bold"
-          style={[
-            pal.text,
-            {
-              paddingHorizontal: 18,
-              paddingTop: 18,
-              paddingBottom: 6,
-            },
-          ]}>
-          <Trans>Logged-out visibility</Trans>
-        </Text>
-        <PwiOptOut />
-      </ScrollView>
-    </CenteredView>
-  )
-}
-
-function PwiOptOut() {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const {currentAccount} = useSession()
-  const {data: profile} = useProfileQuery({did: currentAccount?.did})
-  const updateProfile = useProfileUpdateMutation()
-
-  const isOptedOut =
-    profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
-  const canToggle = profile && !updateProfile.isPending
-
-  const onToggleOptOut = React.useCallback(() => {
-    if (!profile) {
-      return
-    }
-    let wasAdded = false
-    updateProfile.mutate({
-      profile,
-      updates: existing => {
-        // create labels attr if needed
-        existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
-          ? existing.labels
-          : {
-              $type: 'com.atproto.label.defs#selfLabels',
-              values: [],
-            }
-
-        // toggle the label
-        const hasLabel = existing.labels.values.some(
-          l => l.val === '!no-unauthenticated',
-        )
-        if (hasLabel) {
-          wasAdded = false
-          existing.labels.values = existing.labels.values.filter(
-            l => l.val !== '!no-unauthenticated',
-          )
-        } else {
-          wasAdded = true
-          existing.labels.values.push({val: '!no-unauthenticated'})
-        }
-
-        // delete if no longer needed
-        if (existing.labels.values.length === 0) {
-          delete existing.labels
-        }
-        return existing
-      },
-      checkCommitted: res => {
-        const exists = !!res.data.labels?.some(
-          l => l.val === '!no-unauthenticated',
-        )
-        return exists === wasAdded
-      },
-    })
-  }, [updateProfile, profile])
-
-  return (
-    <View style={[pal.view, styles.toggleCard]}>
-      <View
-        style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}>
-        <ToggleButton
-          type="default-light"
-          label={_(
-            msg`Discourage apps from showing my account to logged-out users`,
-          )}
-          labelType="lg"
-          isSelected={isOptedOut}
-          onPress={canToggle ? onToggleOptOut : undefined}
-          style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]}
-        />
-        {updateProfile.isPending && <ActivityIndicator />}
-      </View>
-      <View
-        style={{
-          flexDirection: 'column',
-          gap: 10,
-          paddingLeft: 66,
-          paddingRight: 12,
-          paddingBottom: 10,
-          marginBottom: 64,
-        }}>
-        <Text style={pal.textLight}>
-          <Trans>
-            Bluesky will not show your profile and posts to logged-out users.
-            Other apps may not honor this request. This does not make your
-            account private.
-          </Trans>
-        </Text>
-        <Text style={[pal.textLight, {fontWeight: '500'}]}>
-          <Trans>
-            Note: Bluesky is an open and public network. This setting only
-            limits the visibility of your content on the Bluesky app and
-            website, and other apps may not respect this setting. Your content
-            may still be shown to logged-out users by other apps and websites.
-          </Trans>
-        </Text>
-        <TextLink
-          style={pal.link}
-          href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy"
-          text={_(msg`Learn more about what is public on Bluesky.`)}
-        />
-      </View>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  desktopContainer: {
-    borderLeftWidth: 1,
-    borderRightWidth: 1,
-  },
-  spacer: {
-    height: 6,
-  },
-  linkCard: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    paddingVertical: 12,
-    paddingHorizontal: 18,
-    marginBottom: 1,
-  },
-  toggleCard: {
-    paddingVertical: 8,
-    paddingTop: 2,
-    paddingHorizontal: 6,
-    marginBottom: 1,
-  },
-  iconContainer: {
-    alignItems: 'center',
-    justifyContent: 'center',
-    width: 40,
-    height: 40,
-    borderRadius: 30,
-    marginRight: 12,
-  },
-  noBorder: {
-    borderBottomWidth: 0,
-    borderRightWidth: 0,
-    borderLeftWidth: 0,
-    borderTopWidth: 0,
-  },
-})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b30b4491b..d5a46c5c9 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -1,5 +1,5 @@
 import React, {useMemo} from 'react'
-import {StyleSheet, View} from 'react-native'
+import {StyleSheet} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {
   AppBskyActorDefs,
@@ -7,48 +7,39 @@ import {
   ModerationOpts,
   RichText as RichTextAPI,
 } from '@atproto/api'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {CenteredView} from '../com/util/Views'
 import {ListRef} from '../com/util/List'
-import {ScreenHider} from 'view/com/util/moderation/ScreenHider'
-import {Feed} from 'view/com/posts/Feed'
+import {ScreenHider} from '#/components/moderation/ScreenHider'
 import {ProfileLists} from '../com/lists/ProfileLists'
 import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
-import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader'
 import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
 import {ErrorScreen} from '../com/util/error/ErrorScreen'
-import {EmptyState} from '../com/util/EmptyState'
 import {FAB} from '../com/util/fab/FAB'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {ComposeIcon2} from 'lib/icons'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
-import {
-  FeedDescriptor,
-  resetProfilePostsQueries,
-} from '#/state/queries/post-feed'
+import {resetProfilePostsQueries} from '#/state/queries/post-feed'
 import {useResolveDidQuery} from '#/state/queries/resolve-uri'
 import {useProfileQuery} from '#/state/queries/profile'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
 import {useSession, getAgent} from '#/state/session'
 import {useModerationOpts} from '#/state/queries/preferences'
-import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info'
-import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {useLabelerInfoQuery} from '#/state/queries/labeler'
 import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
 import {cleanError} from '#/lib/strings/errors'
-import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn'
-import {useQueryClient} from '@tanstack/react-query'
 import {useComposerControls} from '#/state/shell/composer'
 import {listenSoftReset} from '#/state/events'
-import {truncateAndInvalidate} from '#/state/queries/util'
-import {Text} from '#/view/com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {isNative} from '#/platform/detection'
 import {isInvalidHandle} from '#/lib/strings/handles'
 
+import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
+import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
+import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
+
 interface SectionRef {
   scrollToTop: () => void
 }
@@ -148,16 +139,24 @@ function ProfileScreenLoaded({
   const setMinimalShellMode = useSetMinimalShellMode()
   const {openComposer} = useComposerControls()
   const {screen, track} = useAnalytics()
+  const {
+    data: labelerInfo,
+    error: labelerError,
+    isLoading: isLabelerLoading,
+  } = useLabelerInfoQuery({
+    did: profile.did,
+    enabled: !!profile.associated?.labeler,
+  })
   const [currentPage, setCurrentPage] = React.useState(0)
   const {_} = useLingui()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-  const extraInfoQuery = useProfileExtraInfoQuery(profile.did)
   const postsSectionRef = React.useRef<SectionRef>(null)
   const repliesSectionRef = React.useRef<SectionRef>(null)
   const mediaSectionRef = React.useRef<SectionRef>(null)
   const likesSectionRef = React.useRef<SectionRef>(null)
   const feedsSectionRef = React.useRef<SectionRef>(null)
   const listsSectionRef = React.useRef<SectionRef>(null)
+  const labelsSectionRef = React.useRef<SectionRef>(null)
 
   useSetTitle(combinedDisplayName(profile))
 
@@ -171,44 +170,75 @@ function ProfileScreenLoaded({
   )
 
   const isMe = profile.did === currentAccount?.did
+  const hasLabeler = !!profile.associated?.labeler
+  const showFiltersTab = hasLabeler
+  const showPostsTab = true
   const showRepliesTab = hasSession
+  const showMediaTab = !hasLabeler
   const showLikesTab = isMe
-  const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens)
-  const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists)
+  const showFeedsTab =
+    hasSession && (isMe || (profile.associated?.feedgens || 0) > 0)
+  const showListsTab =
+    hasSession && (isMe || (profile.associated?.lists || 0) > 0)
+
   const sectionTitles = useMemo<string[]>(() => {
     return [
-      _(msg`Posts`),
+      showFiltersTab ? _(msg`Labels`) : undefined,
+      showListsTab && hasLabeler ? _(msg`Lists`) : undefined,
+      showPostsTab ? _(msg`Posts`) : undefined,
       showRepliesTab ? _(msg`Replies`) : undefined,
-      _(msg`Media`),
+      showMediaTab ? _(msg`Media`) : undefined,
       showLikesTab ? _(msg`Likes`) : undefined,
       showFeedsTab ? _(msg`Feeds`) : undefined,
-      showListsTab ? _(msg`Lists`) : undefined,
+      showListsTab && !hasLabeler ? _(msg`Lists`) : undefined,
     ].filter(Boolean) as string[]
-  }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _])
+  }, [
+    showPostsTab,
+    showRepliesTab,
+    showMediaTab,
+    showLikesTab,
+    showFeedsTab,
+    showListsTab,
+    showFiltersTab,
+    hasLabeler,
+    _,
+  ])
 
   let nextIndex = 0
-  const postsIndex = nextIndex++
+  let filtersIndex: number | null = null
+  let postsIndex: number | null = null
   let repliesIndex: number | null = null
+  let mediaIndex: number | null = null
+  let likesIndex: number | null = null
+  let feedsIndex: number | null = null
+  let listsIndex: number | null = null
+  if (showFiltersTab) {
+    filtersIndex = nextIndex++
+  }
+  if (showPostsTab) {
+    postsIndex = nextIndex++
+  }
   if (showRepliesTab) {
     repliesIndex = nextIndex++
   }
-  const mediaIndex = nextIndex++
-  let likesIndex: number | null = null
+  if (showMediaTab) {
+    mediaIndex = nextIndex++
+  }
   if (showLikesTab) {
     likesIndex = nextIndex++
   }
-  let feedsIndex: number | null = null
   if (showFeedsTab) {
     feedsIndex = nextIndex++
   }
-  let listsIndex: number | null = null
   if (showListsTab) {
     listsIndex = nextIndex++
   }
 
   const scrollSectionToTop = React.useCallback(
     (index: number) => {
-      if (index === postsIndex) {
+      if (index === filtersIndex) {
+        labelsSectionRef.current?.scrollToTop()
+      } else if (index === postsIndex) {
         postsSectionRef.current?.scrollToTop()
       } else if (index === repliesIndex) {
         repliesSectionRef.current?.scrollToTop()
@@ -222,7 +252,15 @@ function ProfileScreenLoaded({
         listsSectionRef.current?.scrollToTop()
       }
     },
-    [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex],
+    [
+      filtersIndex,
+      postsIndex,
+      repliesIndex,
+      mediaIndex,
+      likesIndex,
+      feedsIndex,
+      listsIndex,
+    ],
   )
 
   useFocusEffect(
@@ -278,6 +316,7 @@ function ProfileScreenLoaded({
     return (
       <ProfileHeader
         profile={profile}
+        labeler={labelerInfo}
         descriptionRT={hasDescription ? descriptionRT : null}
         moderationOpts={moderationOpts}
         hideBackButton={hideBackButton}
@@ -286,6 +325,7 @@ function ProfileScreenLoaded({
     )
   }, [
     profile,
+    labelerInfo,
     descriptionRT,
     hasDescription,
     moderationOpts,
@@ -297,8 +337,8 @@ function ProfileScreenLoaded({
     <ScreenHider
       testID="profileView"
       style={styles.container}
-      screenDescription="profile"
-      moderation={moderation.account}>
+      screenDescription={_(msg`profile`)}
+      modui={moderation.ui('profileView')}>
       <PagerWithHeader
         testID="profilePager"
         isHeaderReady={!showPlaceholder}
@@ -306,19 +346,45 @@ function ProfileScreenLoaded({
         onPageSelected={onPageSelected}
         onCurrentPageSelected={onCurrentPageSelected}
         renderHeader={renderHeader}>
-        {({headerHeight, isFocused, scrollElRef}) => (
-          <FeedSection
-            ref={postsSectionRef}
-            feed={`author|${profile.did}|posts_and_author_threads`}
-            headerHeight={headerHeight}
-            isFocused={isFocused}
-            scrollElRef={scrollElRef as ListRef}
-            ignoreFilterFor={profile.did}
-          />
-        )}
+        {showFiltersTab
+          ? ({headerHeight, scrollElRef}) => (
+              <ProfileLabelsSection
+                ref={labelsSectionRef}
+                labelerInfo={labelerInfo}
+                labelerError={labelerError}
+                isLabelerLoading={isLabelerLoading}
+                moderationOpts={moderationOpts}
+                scrollElRef={scrollElRef as ListRef}
+                headerHeight={headerHeight}
+              />
+            )
+          : null}
+        {showListsTab && !!profile.associated?.labeler
+          ? ({headerHeight, isFocused, scrollElRef}) => (
+              <ProfileLists
+                ref={listsSectionRef}
+                did={profile.did}
+                scrollElRef={scrollElRef as ListRef}
+                headerOffset={headerHeight}
+                enabled={isFocused}
+              />
+            )
+          : null}
+        {showPostsTab
+          ? ({headerHeight, isFocused, scrollElRef}) => (
+              <ProfileFeedSection
+                ref={postsSectionRef}
+                feed={`author|${profile.did}|posts_and_author_threads`}
+                headerHeight={headerHeight}
+                isFocused={isFocused}
+                scrollElRef={scrollElRef as ListRef}
+                ignoreFilterFor={profile.did}
+              />
+            )
+          : null}
         {showRepliesTab
           ? ({headerHeight, isFocused, scrollElRef}) => (
-              <FeedSection
+              <ProfileFeedSection
                 ref={repliesSectionRef}
                 feed={`author|${profile.did}|posts_with_replies`}
                 headerHeight={headerHeight}
@@ -328,19 +394,21 @@ function ProfileScreenLoaded({
               />
             )
           : null}
-        {({headerHeight, isFocused, scrollElRef}) => (
-          <FeedSection
-            ref={mediaSectionRef}
-            feed={`author|${profile.did}|posts_with_media`}
-            headerHeight={headerHeight}
-            isFocused={isFocused}
-            scrollElRef={scrollElRef as ListRef}
-            ignoreFilterFor={profile.did}
-          />
-        )}
+        {showMediaTab
+          ? ({headerHeight, isFocused, scrollElRef}) => (
+              <ProfileFeedSection
+                ref={mediaSectionRef}
+                feed={`author|${profile.did}|posts_with_media`}
+                headerHeight={headerHeight}
+                isFocused={isFocused}
+                scrollElRef={scrollElRef as ListRef}
+                ignoreFilterFor={profile.did}
+              />
+            )
+          : null}
         {showLikesTab
           ? ({headerHeight, isFocused, scrollElRef}) => (
-              <FeedSection
+              <ProfileFeedSection
                 ref={likesSectionRef}
                 feed={`likes|${profile.did}`}
                 headerHeight={headerHeight}
@@ -361,7 +429,7 @@ function ProfileScreenLoaded({
               />
             )
           : null}
-        {showListsTab
+        {showListsTab && !profile.associated?.labeler
           ? ({headerHeight, isFocused, scrollElRef}) => (
               <ProfileLists
                 ref={listsSectionRef}
@@ -387,77 +455,6 @@ function ProfileScreenLoaded({
   )
 }
 
-interface FeedSectionProps {
-  feed: FeedDescriptor
-  headerHeight: number
-  isFocused: boolean
-  scrollElRef: ListRef
-  ignoreFilterFor?: string
-}
-const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
-  function FeedSectionImpl(
-    {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
-    ref,
-  ) {
-    const {_} = useLingui()
-    const queryClient = useQueryClient()
-    const [hasNew, setHasNew] = React.useState(false)
-    const [isScrolledDown, setIsScrolledDown] = React.useState(false)
-
-    const onScrollToTop = React.useCallback(() => {
-      scrollElRef.current?.scrollToOffset({
-        animated: isNative,
-        offset: -headerHeight,
-      })
-      truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
-      setHasNew(false)
-    }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
-    React.useImperativeHandle(ref, () => ({
-      scrollToTop: onScrollToTop,
-    }))
-
-    const renderPostsEmpty = React.useCallback(() => {
-      return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
-    }, [_])
-
-    return (
-      <View>
-        <Feed
-          testID="postsFeed"
-          enabled={isFocused}
-          feed={feed}
-          scrollElRef={scrollElRef}
-          onHasNew={setHasNew}
-          onScrolledDownChange={setIsScrolledDown}
-          renderEmptyState={renderPostsEmpty}
-          headerOffset={headerHeight}
-          renderEndOfFeed={ProfileEndOfFeed}
-          ignoreFilterFor={ignoreFilterFor}
-        />
-        {(isScrolledDown || hasNew) && (
-          <LoadLatestBtn
-            onPress={onScrollToTop}
-            label={_(msg`Load new posts`)}
-            showIndicator={hasNew}
-          />
-        )}
-      </View>
-    )
-  },
-)
-
-function ProfileEndOfFeed() {
-  const pal = usePalette('default')
-
-  return (
-    <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
-      <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
-        <Trans>End of feed</Trans>
-      </Text>
-    </View>
-  )
-}
-
 function useRichText(text: string): [RichTextAPI, boolean] {
   const [prevText, setPrevText] = React.useState(text)
   const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index b3a7328c1..416bbc30e 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -35,7 +35,7 @@ import {ComposeIcon2} from 'lib/icons'
 import {logger} from '#/logger'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {
@@ -155,7 +155,7 @@ export function ProfileFeedScreenInner({
   const {_} = useLingui()
   const t = useTheme()
   const {hasSession, currentAccount} = useSession()
-  const {openModal} = useModalControls()
+  const reportDialogControl = useReportDialogControl()
   const {openComposer} = useComposerControls()
   const {track} = useAnalytics()
   const feedSectionRef = React.useRef<SectionRef>(null)
@@ -253,13 +253,8 @@ export function ProfileFeedScreenInner({
   }, [feedInfo, track])
 
   const onPressReport = React.useCallback(() => {
-    if (!feedInfo) return
-    openModal({
-      name: 'report',
-      uri: feedInfo.uri,
-      cid: feedInfo.cid,
-    })
-  }, [openModal, feedInfo])
+    reportDialogControl.open()
+  }, [reportDialogControl])
 
   const onCurrentPageSelected = React.useCallback(
     (index: number) => {
@@ -400,6 +395,14 @@ export function ProfileFeedScreenInner({
 
   return (
     <View style={s.hContentRegion}>
+      <ReportDialog
+        control={reportDialogControl}
+        params={{
+          type: 'feedgen',
+          uri: feedInfo.uri,
+          cid: feedInfo.cid,
+        }}
+      />
       <PagerWithHeader
         items={SECTION_TITLES}
         isHeaderReady={true}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 95046e5b4..351521265 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -39,6 +39,7 @@ import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useModalControls} from '#/state/modals'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
 import {useResolveUriQuery} from '#/state/queries/resolve-uri'
 import {
   useListQuery,
@@ -236,6 +237,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
   const {currentAccount} = useSession()
+  const reportDialogControl = useReportDialogControl()
   const {openModal} = useModalControls()
   const listMuteMutation = useListMuteMutation()
   const listBlockMutation = useListBlockMutation()
@@ -370,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
   ])
 
   const onPressReport = useCallback(() => {
-    openModal({
-      name: 'report',
-      uri: list.uri,
-      cid: list.cid,
-    })
-  }, [openModal, list])
+    reportDialogControl.open()
+  }, [reportDialogControl])
 
   const onPressShare = useCallback(() => {
     const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`)
@@ -550,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       isOwner={list.creator.did === currentAccount?.did}
       creator={list.creator}
       avatarType="list">
+      <ReportDialog
+        control={reportDialogControl}
+        params={{
+          type: 'list',
+          uri: list.uri,
+          cid: list.cid,
+        }}
+      />
       {isCurateList || isPinned ? (
         <Button
           testID={isPinned ? 'unpinBtn' : 'pinBtn'}
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 3b5e190c1..1b96a09af 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -267,6 +267,10 @@ export function SettingsScreen({}: Props) {
     navigation.navigate('Debug')
   }, [navigation])
 
+  const onPressDebugModeration = React.useCallback(() => {
+    navigation.navigate('DebugMod')
+  }, [navigation])
+
   const onPressSavedFeeds = React.useCallback(() => {
     navigation.navigate('SavedFeeds')
   }, [navigation])
@@ -821,6 +825,16 @@ export function SettingsScreen({}: Props) {
                 <Trans>Storybook</Trans>
               </Text>
             </TouchableOpacity>
+            <TouchableOpacity
+              style={[pal.view, styles.linkCardNoIcon]}
+              onPress={onPressDebugModeration}
+              accessibilityRole="button"
+              accessibilityLabel={_(msg`Open storybook page`)}
+              accessibilityHint={_(msg`Opens the storybook page`)}>
+              <Text type="lg" style={pal.text}>
+                <Trans>Debug Moderation</Trans>
+              </Text>
+            </TouchableOpacity>
             <TouchableOpacity
               style={[pal.view, styles.linkCardNoIcon]}
               onPress={onPressResetPreferences}
diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx
index 320db13ff..ad2fff3f4 100644
--- a/src/view/screens/Storybook/Buttons.tsx
+++ b/src/view/screens/Storybook/Buttons.tsx
@@ -129,6 +129,15 @@ export function Buttons() {
           <ButtonIcon icon={Globe} position="left" />
           <ButtonText>Link out</ButtonText>
         </Button>
+
+        <Button
+          variant="gradient"
+          color="gradient_sky"
+          size="tiny"
+          label="Link out">
+          <ButtonIcon icon={Globe} position="left" />
+          <ButtonText>Link out</ButtonText>
+        </Button>
       </View>
 
       <View style={[a.flex_row, a.gap_md, a.align_start]}>
@@ -148,6 +157,14 @@ export function Buttons() {
           label="Link out">
           <ButtonIcon icon={ChevronLeft} />
         </Button>
+        <Button
+          variant="gradient"
+          color="gradient_sunset"
+          size="tiny"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
         <Button
           variant="outline"
           color="primary"
@@ -164,6 +181,14 @@ export function Buttons() {
           label="Link out">
           <ButtonIcon icon={ChevronLeft} />
         </Button>
+        <Button
+          variant="ghost"
+          color="primary"
+          size="tiny"
+          shape="round"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
       </View>
 
       <View style={[a.flex_row, a.gap_md, a.align_start]}>
@@ -183,6 +208,14 @@ export function Buttons() {
           label="Link out">
           <ButtonIcon icon={ChevronLeft} />
         </Button>
+        <Button
+          variant="gradient"
+          color="gradient_sunset"
+          size="tiny"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
         <Button
           variant="outline"
           color="primary"
@@ -199,6 +232,14 @@ export function Buttons() {
           label="Link out">
           <ButtonIcon icon={ChevronLeft} />
         </Button>
+        <Button
+          variant="ghost"
+          color="primary"
+          size="tiny"
+          shape="square"
+          label="Link out">
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
       </View>
     </View>
   )
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index e43d756de..3a2e2f369 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -67,6 +67,7 @@ export function Storybook() {
             </Button>
           </View>
 
+          <Dialogs />
           <ThemeProvider theme="light">
             <Theming />
           </ThemeProvider>
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 4a9483733..8933324ee 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -11,7 +11,7 @@ import {useNavigation, StackActions} from '@react-navigation/native'
 import {
   AppBskyActorDefs,
   moderateProfile,
-  ProfileModeration,
+  ModerationDecision,
 } from '@atproto/api'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -86,7 +86,7 @@ export function SearchProfileCard({
   moderation,
 }: {
   profile: AppBskyActorDefs.ProfileViewBasic
-  moderation: ProfileModeration
+  moderation: ModerationDecision
 }) {
   const pal = usePalette('default')
 
@@ -111,7 +111,7 @@ export function SearchProfileCard({
         <UserAvatar
           size={40}
           avatar={profile.avatar}
-          moderation={moderation.avatar}
+          moderation={moderation.ui('avatar')}
         />
         <View style={{flex: 1}}>
           <Text
@@ -121,7 +121,7 @@ export function SearchProfileCard({
             lineHeight={1.2}>
             {sanitizeDisplayName(
               profile.displayName || sanitizeHandle(profile.handle),
-              moderation.profile,
+              moderation.ui('displayName'),
             )}
           </Text>
           <Text type="md" style={[pal.textLight]} numberOfLines={1}>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 76a7f8fb3..f29183095 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -101,8 +101,8 @@ function ShellInner() {
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
       <MutedWordsDialog />
-      <PortalOutlet />
       <Lightbox />
+      <PortalOutlet />
     </>
   )
 }
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 24024e6e6..02993ac46 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -45,8 +45,9 @@ function ShellInner() {
       <Composer winHeight={0} />
       <ModalsContainer />
       <MutedWordsDialog />
-      <PortalOutlet />
       <Lightbox />
+      <PortalOutlet />
+
       {!isDesktop && isDrawerOpen && (
         <TouchableOpacity
           onPress={() => setDrawerOpen(false)}
diff --git a/yarn.lock b/yarn.lock
index 28d821028..83dee81cd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,15 +34,15 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.10.5":
-  version "0.10.5"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.5.tgz#e778e2843d08690df8df81f24028a7578e9b3cb4"
-  integrity sha512-GYdST5sPKU2JnPmm8x3KqjOSlDiYXrp4GkW7bpQTVLPabnUNq5NLN6HJEoJABjjOAsaLF12rBoV+JpRb1UjNsQ==
-  dependencies:
-    "@atproto/common-web" "^0.2.3"
-    "@atproto/lexicon" "^0.3.2"
-    "@atproto/syntax" "^0.2.0"
-    "@atproto/xrpc" "^0.4.2"
+"@atproto/api@^0.11.2":
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.11.2.tgz#33432cdaeff674d8b21a28601542884bbe1473b2"
+  integrity sha512-VYpavKaEfuXhce9mP/+boZ7BNglnKSReFCRNXhFpa1KnAvWFHGsf+2jP69skEEnZ1XY4/lQhXdDzZdUIqXn2aA==
+  dependencies:
+    "@atproto/common-web" "^0.2.4"
+    "@atproto/lexicon" "^0.3.3"
+    "@atproto/syntax" "^0.2.1"
+    "@atproto/xrpc" "^0.4.3"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
@@ -144,6 +144,16 @@
     uint8arrays "3.0.0"
     zod "^3.21.4"
 
+"@atproto/common-web@^0.2.4":
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.2.4.tgz#a77e0a7f8f025115b3db4f7c530b934b10ab3d82"
+  integrity sha512-6+DOhQcTklFmeiSkZRx6iFeqi4OFtGl4yEDGATk00q4tEcPoPvyOBtYHN6+G9lrfJIfx5RfmggamvXlJv1PxxA==
+  dependencies:
+    graphemer "^1.4.0"
+    multiformats "^9.9.0"
+    uint8arrays "3.0.0"
+    zod "^3.21.4"
+
 "@atproto/common@0.1.0":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
@@ -245,13 +255,13 @@
     multiformats "^9.9.0"
     zod "^3.21.4"
 
-"@atproto/lexicon@^0.3.2":
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c"
-  integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ==
+"@atproto/lexicon@^0.3.3":
+  version "0.3.3"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.3.tgz#e242bf8b024661b3312a8da5b84fe2cd627a6ab1"
+  integrity sha512-6FOjdc3V05JKrtkhjfhHMS7f/4hMJOeHNtoE3Na7iFMpzBz0Lw5sw8kIFKY8pc8IG79qGcFgELyHLsljZYX+5A==
   dependencies:
-    "@atproto/common-web" "^0.2.3"
-    "@atproto/syntax" "^0.2.0"
+    "@atproto/common-web" "^0.2.4"
+    "@atproto/syntax" "^0.2.1"
     iso-datestring-validator "^2.2.2"
     multiformats "^9.9.0"
     zod "^3.21.4"
@@ -351,12 +361,12 @@
   dependencies:
     "@atproto/common-web" "^0.2.3"
 
-"@atproto/syntax@^0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9"
-  integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ==
+"@atproto/syntax@^0.2.1":
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.1.tgz#3abcb050c122e7ce80fc25b49cd7d86c63dc6c2e"
+  integrity sha512-ImOuiICtB5h78j90hAYOfTYzr5q5Wut0irNdELiogA3i74a8EXThe+j6Tj8snanYggrShbu5c6BDc1tVj477Yw==
   dependencies:
-    "@atproto/common-web" "^0.2.3"
+    "@atproto/common-web" "^0.2.4"
 
 "@atproto/xrpc-server@^0.4.2":
   version "0.4.2"
@@ -383,12 +393,12 @@
     "@atproto/lexicon" "^0.3.1"
     zod "^3.21.4"
 
-"@atproto/xrpc@^0.4.2":
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda"
-  integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA==
+"@atproto/xrpc@^0.4.3":
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.3.tgz#19d85cb7f343369363e664917945dec1f89fde97"
+  integrity sha512-0rn3abHORG0T93mci8WW97Cpg2ClU2aCtTq5rxdCPRsl9P4tyP+8F4snbkrIaMbVO05Rd9D9gFwtWs5Z473pCQ==
   dependencies:
-    "@atproto/lexicon" "^0.3.2"
+    "@atproto/lexicon" "^0.3.3"
     zod "^3.21.4"
 
 "@aws-crypto/crc32@3.0.0":
@@ -7244,11 +7254,37 @@
   dependencies:
     "@tamagui/constants" "1.84.1"
 
+"@tanstack/query-async-storage-persister@^5.25.0":
+  version "5.25.0"
+  resolved "https://registry.yarnpkg.com/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.25.0.tgz#0e8a2a781b8e32a81a5d02a688d6fcdfd055235b"
+  integrity sha512-58UTp1CuLr2mehsJRMOd8IZPtYGHFeL+uHnHyRd1kmbwo7wDaa8HXstiBdTRq5KokxIXy9FiFbA06LtKuOiwMQ==
+  dependencies:
+    "@tanstack/query-persist-client-core" "5.25.0"
+
+"@tanstack/query-core@5.25.0":
+  version "5.25.0"
+  resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.25.0.tgz#e08ed0a9fad34c8005d1a282e57280031ac50cdc"
+  integrity sha512-vlobHP64HTuSE68lWF1mEhwSRC5Q7gaT+a/m9S+ItuN+ruSOxe1rFnR9j0ACWQ314BPhBEVKfBQ6mHL0OWfdbQ==
+
 "@tanstack/query-core@5.8.1":
   version "5.8.1"
   resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.8.1.tgz#5215a028370d9b2f32e83787a0ea119e2f977996"
   integrity sha512-Y0enatz2zQXBAsd7XmajlCs+WaitdR7dIFkqz9Xd7HL4KV04JOigWVreYseTmNH7YFSBSC/BJ9uuNp1MAf+GfA==
 
+"@tanstack/query-persist-client-core@5.25.0":
+  version "5.25.0"
+  resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.25.0.tgz#52fa634a8067d7b965854a532a33077fd4df0eff"
+  integrity sha512-sEUsEZ/XWkOosO45CDBI5nj5woCS+DUd9Dk8pGpU8MkeH0EVd3p4N5CdbjNhrreyy5Krf3rpNaiRN9ygLX/rWA==
+  dependencies:
+    "@tanstack/query-core" "5.25.0"
+
+"@tanstack/react-query-persist-client@^5.25.0":
+  version "5.25.0"
+  resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.25.0.tgz#ecbd1362cd6fd94e723d54f5af477d0812852dab"
+  integrity sha512-j1+GyFj4UQGWuiFZoDUVJZS+wxqKd9SGvPlyHG619zzYNN+QQu4B5uvvHc6U8MroM377EOBOuLKK3W6qsAdahQ==
+  dependencies:
+    "@tanstack/query-persist-client-core" "5.25.0"
+
 "@tanstack/react-query@^5.8.1":
   version "5.8.1"
   resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.8.1.tgz#22a122016e23a39acd90341954a895980ec21ade"
-- 
cgit 1.4.1

