about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Button.tsx28
-rw-r--r--src/components/Dialog/index.tsx4
-rw-r--r--src/components/Dialog/index.web.tsx2
-rw-r--r--src/components/GradientFill.tsx27
-rw-r--r--src/components/LabelingServiceCard/index.tsx182
-rw-r--r--src/components/LikedByList.tsx109
-rw-r--r--src/components/LikesDialog.tsx131
-rw-r--r--src/components/Link.tsx2
-rw-r--r--src/components/Lists.tsx2
-rw-r--r--src/components/Menu/index.web.tsx2
-rw-r--r--src/components/Prompt.tsx2
-rw-r--r--src/components/ReportDialog/SelectLabelerView.tsx115
-rw-r--r--src/components/ReportDialog/SelectReportOptionView.tsx199
-rw-r--r--src/components/ReportDialog/SubmitView.tsx264
-rw-r--r--src/components/ReportDialog/const.ts1
-rw-r--r--src/components/ReportDialog/index.tsx95
-rw-r--r--src/components/ReportDialog/types.ts15
-rw-r--r--src/components/TagMenu/index.tsx2
-rw-r--r--src/components/TagMenu/index.web.tsx2
-rw-r--r--src/components/Typography.tsx9
-rw-r--r--src/components/dialogs/BirthDateSettings.tsx124
-rw-r--r--src/components/dialogs/Context.tsx2
-rw-r--r--src/components/dialogs/MutedWords.tsx4
-rw-r--r--src/components/forms/Toggle.tsx40
-rw-r--r--src/components/forms/ToggleButton.tsx8
-rw-r--r--src/components/hooks/useDelayedLoading.ts15
-rw-r--r--src/components/icons/ArrowTriangle.tsx5
-rw-r--r--src/components/icons/Bars.tsx5
-rw-r--r--src/components/icons/Chevron.tsx8
-rw-r--r--src/components/icons/CircleBanSign.tsx5
-rw-r--r--src/components/icons/Gear.tsx5
-rw-r--r--src/components/icons/Group.tsx (renamed from src/components/icons/Group3.tsx)0
-rw-r--r--src/components/icons/RaisingHand.tsx5
-rw-r--r--src/components/icons/Shield.tsx5
-rw-r--r--src/components/icons/SquareArrowTopRight.tsx5
-rw-r--r--src/components/icons/SquareBehindSquare4.tsx5
-rw-r--r--src/components/moderation/ContentHider.tsx182
-rw-r--r--src/components/moderation/GlobalModerationLabelPref.tsx93
-rw-r--r--src/components/moderation/LabelsOnMe.tsx83
-rw-r--r--src/components/moderation/LabelsOnMeDialog.tsx262
-rw-r--r--src/components/moderation/ModerationDetailsDialog.tsx148
-rw-r--r--src/components/moderation/ModerationLabelPref.tsx154
-rw-r--r--src/components/moderation/PostAlerts.tsx66
-rw-r--r--src/components/moderation/PostHider.tsx129
-rw-r--r--src/components/moderation/ProfileHeaderAlerts.tsx66
-rw-r--r--src/components/moderation/ScreenHider.tsx171
46 files changed, 2740 insertions, 48 deletions
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.tsx b/src/components/Dialog/index.tsx
index 5f6edeac7..0da2919c5 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -23,6 +23,7 @@ import {
   DialogInnerProps,
 } from '#/components/Dialog/types'
 import {Context} from '#/components/Dialog/context'
+import {isNative} from 'platform/detection'
 
 export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
@@ -221,7 +222,8 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
           borderTopRightRadius: 40,
         },
         flatten(style),
-      ]}>
+      ]}
+      contentContainerStyle={isNative ? a.pb_4xl : undefined}>
       {children}
       <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
     </BottomSheetScrollView>
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/Prompt.tsx b/src/components/Prompt.tsx
index 1b9348d9c..b81b20707 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -3,7 +3,6 @@ import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {isNative} from '#/platform/detection'
 import {useTheme, atoms as a, useBreakpoints} from '#/alf'
 import {Text} from '#/components/Typography'
 import {Button, ButtonColor, ButtonText} from '#/components/Button'
@@ -86,7 +85,6 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
         gtMobile
           ? [a.flex_row, a.flex_row_reverse, a.justify_start]
           : [a.flex_col],
-        isNative && [a.pb_4xl],
       ]}>
       {children}
     </View>
diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx
new file mode 100644
index 000000000..300114fc4
--- /dev/null
+++ b/src/components/ReportDialog/SelectLabelerView.tsx
@@ -0,0 +1,115 @@
+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'
+
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, useButtonContext} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+
+import {ReportDialogProps} from './types'
+
+export function SelectLabelerView({
+  ...props
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  onSelectLabeler: (v: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.justify_center, a.gap_sm]}>
+        <Text style={[a.text_2xl, a.font_bold]}>
+          <Trans>Select moderation service</Trans>
+        </Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          <Trans>Who do you want to send this report to?</Trans>
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
+        {props.labelers.map(labeler => {
+          return (
+            <Button
+              key={labeler.creator.did}
+              label={_(msg`Send report to ${labeler.creator.displayName}`)}
+              onPress={() => props.onSelectLabeler(labeler.creator.did)}>
+              <LabelerButton
+                title={labeler.creator.displayName || labeler.creator.handle}
+                description={labeler.creator.description || ''}
+              />
+            </Button>
+          )
+        })}
+      </View>
+    </View>
+  )
+}
+
+function LabelerButton({
+  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}]} numberOfLines={3}>
+          {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/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
new file mode 100644
index 000000000..037a62fce
--- /dev/null
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -0,0 +1,199 @@
+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,
+  ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
+} 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
+  goBack: () => 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]}>
+      {props.labelers?.length > 1 ? (
+        <Button
+          size="small"
+          variant="solid"
+          color="secondary"
+          shape="round"
+          label={_(msg`Go back to previous step`)}
+          onPress={props.goBack}>
+          <ButtonIcon icon={ChevronLeft} />
+        </Button>
+      ) : null}
+
+      <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..d47211c81
--- /dev/null
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -0,0 +1,264 @@
+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,
+  selectedLabeler,
+  selectedReportOption,
+  goBack,
+  onSubmitComplete,
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  selectedLabeler: string
+  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[]>([
+    selectedLabeler,
+  ])
+  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..f01ff3f3b
--- /dev/null
+++ b/src/components/ReportDialog/index.tsx
@@ -0,0 +1,95 @@
+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 {SelectLabelerView} from './SelectLabelerView'
+import {SelectReportOptionView} from './SelectReportOptionView'
+import {SubmitView} from './SubmitView'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+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)
+
+  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>
+      ) : (
+        <ReportDialogLoaded labelers={labelers} {...props} />
+      )}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+function ReportDialogLoaded(
+  props: ReportDialogProps & {
+    labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  },
+) {
+  const [selectedLabeler, setSelectedLabeler] = React.useState<
+    string | undefined
+  >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
+  const [selectedReportOption, setSelectedReportOption] = React.useState<
+    ReportOption | undefined
+  >()
+
+  if (selectedReportOption && selectedLabeler) {
+    return (
+      <SubmitView
+        {...props}
+        selectedLabeler={selectedLabeler}
+        selectedReportOption={selectedReportOption}
+        goBack={() => setSelectedReportOption(undefined)}
+        onSubmitComplete={() => props.control.close()}
+      />
+    )
+  }
+  if (selectedLabeler) {
+    return (
+      <SelectReportOptionView
+        {...props}
+        goBack={() => setSelectedLabeler(undefined)}
+        onSelectReportOption={setSelectedReportOption}
+      />
+    )
+  }
+  return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} />
+}
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/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx
new file mode 100644
index 000000000..62c95c78d
--- /dev/null
+++ b/src/components/dialogs/BirthDateSettings.tsx
@@ -0,0 +1,124 @@
+import React from 'react'
+import {useLingui} from '@lingui/react'
+import {Trans, msg} from '@lingui/macro'
+
+import * as Dialog from '#/components/Dialog'
+import {Text} from '../Typography'
+import {DateInput} from '#/view/com/util/forms/DateInput'
+import {logger} from '#/logger'
+import {
+  usePreferencesSetBirthDateMutation,
+  UsePreferencesQueryResponse,
+} from '#/state/queries/preferences'
+import {Button, ButtonText} from '../Button'
+import {atoms as a, useTheme} from '#/alf'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {cleanError} from '#/lib/strings/errors'
+import {ActivityIndicator, View} from 'react-native'
+import {isIOS, isWeb} from '#/platform/detection'
+
+export function BirthDateSettingsDialog({
+  control,
+  preferences,
+}: {
+  control: Dialog.DialogControlProps
+  preferences: UsePreferencesQueryResponse | undefined
+}) {
+  const {_} = useLingui()
+  const {isPending, isError, error, mutateAsync} =
+    usePreferencesSetBirthDateMutation()
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner label={_(msg`My Birthday`)}>
+        {preferences && !isPending ? (
+          <BirthdayInner
+            control={control}
+            preferences={preferences}
+            isError={isError}
+            error={error}
+            setBirthDate={mutateAsync}
+          />
+        ) : (
+          <ActivityIndicator size="large" style={a.my_5xl} />
+        )}
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+
+function BirthdayInner({
+  control,
+  preferences,
+  isError,
+  error,
+  setBirthDate,
+}: {
+  control: Dialog.DialogControlProps
+  preferences: UsePreferencesQueryResponse
+  isError: boolean
+  error: unknown
+  setBirthDate: (args: {birthDate: Date}) => Promise<unknown>
+}) {
+  const {_} = useLingui()
+  const [date, setDate] = React.useState(preferences.birthDate || new Date())
+  const t = useTheme()
+
+  const hasChanged = date !== preferences.birthDate
+
+  const onSave = React.useCallback(async () => {
+    try {
+      // skip if date is the same
+      if (hasChanged) {
+        await setBirthDate({birthDate: date})
+      }
+      control.close()
+    } catch (e) {
+      logger.error(`setBirthDate failed`, {message: e})
+    }
+  }, [date, setBirthDate, control, hasChanged])
+
+  return (
+    <View style={a.gap_lg} testID="birthDateSettingsDialog">
+      <View style={[a.gap_sm]}>
+        <Text style={[a.text_2xl, a.font_bold]}>
+          <Trans>My Birthday</Trans>
+        </Text>
+        <Text style={t.atoms.text_contrast_medium}>
+          <Trans>This information is not shared with other users.</Trans>
+        </Text>
+      </View>
+      <View style={isIOS && [a.w_full, a.align_center]}>
+        <DateInput
+          handleAsUTC
+          testID="birthdayInput"
+          value={date}
+          onChange={setDate}
+          buttonType="default-light"
+          buttonStyle={[a.rounded_sm]}
+          buttonLabelType="lg"
+          accessibilityLabel={_(msg`Birthday`)}
+          accessibilityHint={_(msg`Enter your birth date`)}
+          accessibilityLabelledBy="birthDate"
+        />
+      </View>
+      {isError ? (
+        <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
+      ) : undefined}
+
+      <View style={isWeb && [a.flex_row, a.justify_end]}>
+        <Button
+          label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
+          size={isWeb ? 'small' : 'medium'}
+          onPress={onSave}
+          variant="solid"
+          color="primary">
+          <ButtonText>
+            {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
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/Group3.tsx b/src/components/icons/Group.tsx
index 9e5ab8893..9e5ab8893 100644
--- a/src/components/icons/Group3.tsx
+++ b/src/components/icons/Group.tsx
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>
+  )
+}