about summary refs log tree commit diff
path: root/src/components/moderation
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/moderation')
-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
10 files changed, 1354 insertions, 0 deletions
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>
+  )
+}