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/Admonition.tsx28
-rw-r--r--src/components/moderation/ReportDialog/action.ts99
-rw-r--r--src/components/moderation/ReportDialog/const.ts1
-rw-r--r--src/components/moderation/ReportDialog/copy.ts49
-rw-r--r--src/components/moderation/ReportDialog/index.tsx654
-rw-r--r--src/components/moderation/ReportDialog/state.ts109
-rw-r--r--src/components/moderation/ReportDialog/types.ts67
-rw-r--r--src/components/moderation/ReportDialog/utils/parseReportSubject.ts91
-rw-r--r--src/components/moderation/ReportDialog/utils/useReportOptions.ts121
9 files changed, 1209 insertions, 10 deletions
diff --git a/src/components/Admonition.tsx b/src/components/Admonition.tsx
index 8b01a8aba..8df4934be 100644
--- a/src/components/Admonition.tsx
+++ b/src/components/Admonition.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import {StyleProp, View, ViewStyle} from 'react-native'
 
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button as BaseButton, ButtonProps} from '#/components/Button'
 import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
 import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
 import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
@@ -49,22 +50,29 @@ export function Text({
   return (
     <BaseText
       {...rest}
-      style={[
-        a.flex_1,
-        a.text_sm,
-        a.leading_snug,
-        {
-          paddingTop: 1,
-        },
-        style,
-      ]}>
+      style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
       {children}
     </BaseText>
   )
 }
 
+export function Button({
+  children,
+  ...props
+}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
+  return (
+    <BaseButton size="tiny" variant="outline" color="secondary" {...props}>
+      {children}
+    </BaseButton>
+  )
+}
+
 export function Row({children}: {children: React.ReactNode}) {
-  return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
+  return (
+    <View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
+      {children}
+    </View>
+  )
 }
 
 export function Outer({
diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts
new file mode 100644
index 000000000..fde2c7898
--- /dev/null
+++ b/src/components/moderation/ReportDialog/action.ts
@@ -0,0 +1,99 @@
+import {
+  $Typed,
+  ChatBskyConvoDefs,
+  ComAtprotoModerationCreateReport,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useAgent} from '#/state/session'
+import {ReportState} from './state'
+import {ParsedReportSubject} from './types'
+
+export function useSubmitReportMutation() {
+  const {_} = useLingui()
+  const agent = useAgent()
+
+  return useMutation({
+    async mutationFn({
+      subject,
+      state,
+    }: {
+      subject: ParsedReportSubject
+      state: ReportState
+    }) {
+      if (!state.selectedOption) {
+        throw new Error(_(msg`Please select a reason for this report`))
+      }
+      if (!state.selectedLabeler) {
+        throw new Error(_(msg`Please select a moderation service`))
+      }
+
+      let report:
+        | ComAtprotoModerationCreateReport.InputSchema
+        | (Omit<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
+            subject: $Typed<ChatBskyConvoDefs.MessageRef>
+          })
+
+      switch (subject.type) {
+        case 'account': {
+          report = {
+            reasonType: state.selectedOption.reason,
+            reason: state.details,
+            subject: {
+              $type: 'com.atproto.admin.defs#repoRef',
+              did: subject.did,
+            },
+          }
+          break
+        }
+        case 'post':
+        case 'list':
+        case 'feed':
+        case 'starterPack': {
+          report = {
+            reasonType: state.selectedOption.reason,
+            reason: state.details,
+            subject: {
+              $type: 'com.atproto.repo.strongRef',
+              uri: subject.uri,
+              cid: subject.cid,
+            },
+          }
+          break
+        }
+        case 'chatMessage': {
+          report = {
+            reasonType: state.selectedOption.reason,
+            reason: state.details,
+            subject: {
+              $type: 'chat.bsky.convo.defs#messageRef',
+              messageId: subject.message.id,
+              convoId: subject.convoId,
+              did: subject.message.sender.did,
+            },
+          }
+          break
+        }
+      }
+
+      if (__DEV__) {
+        logger.info('Submitting report', {
+          labeler: {
+            handle: state.selectedLabeler.creator.handle,
+          },
+          report,
+        })
+      } else {
+        await agent.createModerationReport(report, {
+          encoding: 'application/json',
+          headers: {
+            'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`,
+          },
+        })
+      }
+    },
+  })
+}
diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts
new file mode 100644
index 000000000..30c9aff88
--- /dev/null
+++ b/src/components/moderation/ReportDialog/const.ts
@@ -0,0 +1 @@
+export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
diff --git a/src/components/moderation/ReportDialog/copy.ts b/src/components/moderation/ReportDialog/copy.ts
new file mode 100644
index 000000000..87e199f98
--- /dev/null
+++ b/src/components/moderation/ReportDialog/copy.ts
@@ -0,0 +1,49 @@
+import {useMemo} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ParsedReportSubject} from './types'
+
+export function useCopyForSubject(subject: ParsedReportSubject) {
+  const {_} = useLingui()
+  return useMemo(() => {
+    switch (subject.type) {
+      case 'account': {
+        return {
+          title: _(msg`Report this user`),
+          subtitle: _(msg`Why should this user be reviewed?`),
+        }
+      }
+      case 'post': {
+        return {
+          title: _(msg`Report this post`),
+          subtitle: _(msg`Why should this post be reviewed?`),
+        }
+      }
+      case 'list': {
+        return {
+          title: _(msg`Report this list`),
+          subtitle: _(msg`Why should this list be reviewed?`),
+        }
+      }
+      case 'feed': {
+        return {
+          title: _(msg`Report this feed`),
+          subtitle: _(msg`Why should this feed be reviewed?`),
+        }
+      }
+      case 'starterPack': {
+        return {
+          title: _(msg`Report this starter pack`),
+          subtitle: _(msg`Why should this starter pack be reviewed?`),
+        }
+      }
+      case 'chatMessage': {
+        return {
+          title: _(msg`Report this message`),
+          subtitle: _(msg`Why should this message be reviewed?`),
+        }
+      }
+    }
+  }, [_, subject])
+}
diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx
new file mode 100644
index 000000000..7115324e6
--- /dev/null
+++ b/src/components/moderation/ReportDialog/index.tsx
@@ -0,0 +1,654 @@
+import React from 'react'
+import {Pressable, View} from 'react-native'
+import {ScrollView} from 'react-native-gesture-handler'
+import {AppBskyLabelerDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {wait} from '#/lib/async/wait'
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {useMyLabelersQuery} from '#/state/queries/preferences'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useGutters, useTheme} from '#/alf'
+import * as Admonition from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
+import {
+  Check_Stroke2_Corner0_Rounded as CheckThin,
+  CheckThick_Stroke2_Corner0_Rounded as Check,
+} from '#/components/icons/Check'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {createStaticClick, InlineLinkText, Link} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {useSubmitReportMutation} from './action'
+import {DMCA_LINK} from './const'
+import {useCopyForSubject} from './copy'
+import {initialState, reducer} from './state'
+import {ReportDialogProps, ReportSubject} from './types'
+import {parseReportSubject} from './utils/parseReportSubject'
+import {ReportOption, useReportOptions} from './utils/useReportOptions'
+
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+export function ReportDialog(
+  props: Omit<ReportDialogProps, 'subject'> & {
+    subject: ReportSubject
+  },
+) {
+  const subject = React.useMemo(
+    () => parseReportSubject(props.subject),
+    [props.subject],
+  )
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+      {subject ? <Inner {...props} subject={subject} /> : <Invalid />}
+    </Dialog.Outer>
+  )
+}
+
+/**
+ * This should only be shown if the dialog is configured incorrectly by a
+ * developer, but nevertheless we should have a graceful fallback.
+ */
+function Invalid() {
+  const {_} = useLingui()
+  return (
+    <Dialog.ScrollableInner label={_(msg`Report dialog`)}>
+      <Text style={[a.font_heavy, a.text_xl, a.leading_snug, a.pb_xs]}>
+        <Trans>Invalid report subject</Trans>
+      </Text>
+      <Text style={[a.text_md, a.leading_snug]}>
+        <Trans>
+          Something wasn't quite right with the data you're trying to report.
+          Please contact support.
+        </Trans>
+      </Text>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+function Inner(props: ReportDialogProps) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const ref = React.useRef<ScrollView>(null)
+  const {
+    data: allLabelers,
+    isLoading: isLabelerLoading,
+    error: labelersLoadError,
+    refetch: refetchLabelers,
+  } = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
+  const isLoading = useDelayedLoading(500, isLabelerLoading)
+  const copy = useCopyForSubject(props.subject)
+  const reportOptions = useReportOptions()
+  const [state, dispatch] = React.useReducer(reducer, initialState)
+
+  /**
+   * Submission handling
+   */
+  const {mutateAsync: submitReport} = useSubmitReportMutation()
+  const [isPending, setPending] = React.useState(false)
+  const [isSuccess, setSuccess] = React.useState(false)
+
+  /**
+   * Labelers that support this `subject` and its NSID collection
+   */
+  const supportedLabelers = React.useMemo(() => {
+    if (!allLabelers) return []
+    return allLabelers
+      .filter(l => {
+        const subjectTypes: string[] | undefined = l.subjectTypes
+        if (subjectTypes === undefined) return true
+        if (props.subject.type === 'account') {
+          return subjectTypes.includes('account')
+        } else if (props.subject.type === 'chatMessage') {
+          return subjectTypes.includes('chat')
+        } else {
+          return subjectTypes.includes('record')
+        }
+      })
+      .filter(l => {
+        const collections: string[] | undefined = l.subjectCollections
+        if (collections === undefined) return true
+        // all chat collections accepted, since only Bluesky handles chats
+        if (props.subject.type === 'chatMessage') return true
+        return collections.includes(props.subject.nsid)
+      })
+      .filter(l => {
+        if (!state.selectedOption) return true
+        const reasonTypes: string[] | undefined = l.reasonTypes
+        if (reasonTypes === undefined) return true
+        return reasonTypes.includes(state.selectedOption.reason)
+      })
+  }, [props, allLabelers, state.selectedOption])
+  const hasSupportedLabelers = !!supportedLabelers.length
+  const hasSingleSupportedLabeler = supportedLabelers.length === 1
+
+  const onSubmit = React.useCallback(async () => {
+    dispatch({type: 'clearError'})
+
+    try {
+      setPending(true)
+      // wait at least 1s, make it feel substantial
+      await wait(
+        1e3,
+        submitReport({
+          subject: props.subject,
+          state,
+        }),
+      )
+      setSuccess(true)
+      // give time for user feedback
+      setTimeout(() => {
+        props.control.close()
+      }, 1e3)
+    } catch (e: any) {
+      logger.error(e, {
+        source: 'ReportDialog',
+      })
+      dispatch({
+        type: 'setError',
+        error: _(msg`Something went wrong. Please try again.`),
+      })
+    } finally {
+      setPending(false)
+    }
+  }, [_, submitReport, state, dispatch, props, setPending, setSuccess])
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Report dialog`)}
+      ref={ref}
+      style={[a.w_full, {maxWidth: 500}]}>
+      <View style={[a.gap_2xl, isNative && a.pt_md]}>
+        <StepOuter>
+          <StepTitle
+            index={1}
+            title={copy.subtitle}
+            activeIndex1={state.activeStepIndex1}
+          />
+          {isLoading ? (
+            <View style={[a.gap_sm]}>
+              <OptionCardSkeleton />
+              <OptionCardSkeleton />
+              <OptionCardSkeleton />
+              <OptionCardSkeleton />
+              <OptionCardSkeleton />
+              {/* Here to capture focus for a hot sec to prevent flash */}
+              <Pressable accessible={false} />
+            </View>
+          ) : labelersLoadError || !allLabelers ? (
+            <Admonition.Outer type="error">
+              <Admonition.Row>
+                <Admonition.Icon />
+                <Admonition.Text>
+                  <Trans>Something went wrong, please try again</Trans>
+                </Admonition.Text>
+                <Admonition.Button
+                  label={_(msg`Retry loading report options`)}
+                  onPress={() => refetchLabelers()}>
+                  <ButtonText>
+                    <Trans>Retry</Trans>
+                  </ButtonText>
+                  <ButtonIcon icon={Retry} />
+                </Admonition.Button>
+              </Admonition.Row>
+            </Admonition.Outer>
+          ) : (
+            <>
+              {state.selectedOption ? (
+                <View style={[a.flex_row, a.align_center, a.gap_md]}>
+                  <View style={[a.flex_1]}>
+                    <OptionCard option={state.selectedOption} />
+                  </View>
+                  <Button
+                    label={_(msg`Change report reason`)}
+                    size="tiny"
+                    variant="solid"
+                    color="secondary"
+                    shape="round"
+                    onPress={() => {
+                      dispatch({type: 'clearOption'})
+                    }}>
+                    <ButtonIcon icon={X} />
+                  </Button>
+                </View>
+              ) : (
+                <View style={[a.gap_sm]}>
+                  {reportOptions[props.subject.type].map(o => (
+                    <OptionCard
+                      key={o.reason}
+                      option={o}
+                      onSelect={() => {
+                        dispatch({type: 'selectOption', option: o})
+                      }}
+                    />
+                  ))}
+
+                  {['post', 'account'].includes(props.subject.type) && (
+                    <Link
+                      to={DMCA_LINK}
+                      label={_(
+                        msg`View details for reporting a copyright violation`,
+                      )}>
+                      {({hovered, pressed}) => (
+                        <View
+                          style={[
+                            a.flex_row,
+                            a.align_center,
+                            a.w_full,
+                            a.px_md,
+                            a.py_sm,
+                            a.rounded_sm,
+                            a.border,
+                            hovered || pressed
+                              ? [t.atoms.border_contrast_high]
+                              : [t.atoms.border_contrast_low],
+                          ]}>
+                          <Text style={[a.flex_1, a.italic, a.leading_snug]}>
+                            <Trans>Need to report a copyright violation?</Trans>
+                          </Text>
+                          <SquareArrowTopRight
+                            size="sm"
+                            fill={t.atoms.text.color}
+                          />
+                        </View>
+                      )}
+                    </Link>
+                  )}
+                </View>
+              )}
+            </>
+          )}
+        </StepOuter>
+
+        <StepOuter>
+          <StepTitle
+            index={2}
+            title={_(msg`Select moderation service`)}
+            activeIndex1={state.activeStepIndex1}
+          />
+          {state.activeStepIndex1 >= 2 && (
+            <>
+              {state.selectedLabeler ? (
+                <>
+                  {hasSingleSupportedLabeler ? (
+                    <LabelerCard labeler={state.selectedLabeler} />
+                  ) : (
+                    <View style={[a.flex_row, a.align_center, a.gap_md]}>
+                      <View style={[a.flex_1]}>
+                        <LabelerCard labeler={state.selectedLabeler} />
+                      </View>
+                      <Button
+                        label={_(msg`Change moderation service`)}
+                        size="tiny"
+                        variant="solid"
+                        color="secondary"
+                        shape="round"
+                        onPress={() => {
+                          dispatch({type: 'clearLabeler'})
+                        }}>
+                        <ButtonIcon icon={X} />
+                      </Button>
+                    </View>
+                  )}
+                </>
+              ) : (
+                <>
+                  {hasSupportedLabelers ? (
+                    <View style={[a.gap_sm]}>
+                      {hasSingleSupportedLabeler ? (
+                        <>
+                          <LabelerCard labeler={supportedLabelers[0]} />
+                          <ActionOnce
+                            check={() => !state.selectedLabeler}
+                            callback={() => {
+                              dispatch({
+                                type: 'selectLabeler',
+                                labeler: supportedLabelers[0],
+                              })
+                            }}
+                          />
+                        </>
+                      ) : (
+                        <>
+                          {supportedLabelers.map(l => (
+                            <LabelerCard
+                              key={l.creator.did}
+                              labeler={l}
+                              onSelect={() => {
+                                dispatch({type: 'selectLabeler', labeler: l})
+                              }}
+                            />
+                          ))}
+                        </>
+                      )}
+                    </View>
+                  ) : (
+                    // should never happen in our app
+                    <Admonition.Admonition type="warning">
+                      <Trans>
+                        Unfortunately, none of your subscribed labelers supports
+                        this report type.
+                      </Trans>
+                    </Admonition.Admonition>
+                  )}
+                </>
+              )}
+            </>
+          )}
+        </StepOuter>
+
+        <StepOuter>
+          <StepTitle
+            index={3}
+            title={_(msg`Submit report`)}
+            activeIndex1={state.activeStepIndex1}
+          />
+          {state.activeStepIndex1 === 3 && (
+            <>
+              <View style={[a.pb_xs, a.gap_xs]}>
+                <Text style={[a.leading_snug, a.pb_xs]}>
+                  <Trans>
+                    Your report will be sent to{' '}
+                    <Text style={[a.font_bold, a.leading_snug]}>
+                      {state.selectedLabeler?.creator.displayName}
+                    </Text>
+                    .
+                  </Trans>{' '}
+                  {!state.detailsOpen ? (
+                    <InlineLinkText
+                      label={_(msg`Add more details (optional)`)}
+                      {...createStaticClick(() => {
+                        dispatch({type: 'showDetails'})
+                      })}>
+                      <Trans>Add more details (optional)</Trans>
+                    </InlineLinkText>
+                  ) : null}
+                </Text>
+
+                {state.detailsOpen && (
+                  <View>
+                    <Dialog.Input
+                      multiline
+                      value={state.details}
+                      onChangeText={details => {
+                        dispatch({type: 'setDetails', details})
+                      }}
+                      label={_(msg`Additional details (limit 300 characters)`)}
+                      style={{paddingRight: 60}}
+                      numberOfLines={4}
+                    />
+                    <View
+                      style={[
+                        a.absolute,
+                        a.flex_row,
+                        a.align_center,
+                        a.pr_md,
+                        a.pb_sm,
+                        {
+                          bottom: 0,
+                          right: 0,
+                        },
+                      ]}>
+                      <CharProgress count={state.details?.length || 0} />
+                    </View>
+                  </View>
+                )}
+              </View>
+              <Button
+                label={_(msg`Submit report`)}
+                size="large"
+                variant="solid"
+                color="primary"
+                disabled={isPending || isSuccess}
+                onPress={onSubmit}>
+                <ButtonText>
+                  <Trans>Submit report</Trans>
+                </ButtonText>
+                <ButtonIcon
+                  icon={isSuccess ? CheckThin : isPending ? Loader : PaperPlane}
+                />
+              </Button>
+
+              {state.error && (
+                <Admonition.Admonition type="error">
+                  {state.error}
+                </Admonition.Admonition>
+              )}
+            </>
+          )}
+        </StepOuter>
+      </View>
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
+
+function ActionOnce({
+  check,
+  callback,
+}: {
+  check: () => boolean
+  callback: () => void
+}) {
+  React.useEffect(() => {
+    if (check()) {
+      callback()
+    }
+  }, [check, callback])
+  return null
+}
+
+function StepOuter({children}: {children: React.ReactNode}) {
+  return <View style={[a.gap_md, a.w_full]}>{children}</View>
+}
+
+function StepTitle({
+  index,
+  title,
+  activeIndex1,
+}: {
+  index: number
+  title: string
+  activeIndex1: number
+}) {
+  const t = useTheme()
+  const active = activeIndex1 === index
+  const completed = activeIndex1 > index
+  return (
+    <View style={[a.flex_row, a.gap_sm, a.pr_3xl]}>
+      <View
+        style={[
+          a.justify_center,
+          a.align_center,
+          a.rounded_full,
+          a.border,
+          {
+            width: 24,
+            height: 24,
+            backgroundColor: active
+              ? t.palette.primary_500
+              : completed
+              ? t.palette.primary_100
+              : t.atoms.bg_contrast_25.backgroundColor,
+            borderColor: active
+              ? t.palette.primary_500
+              : completed
+              ? t.palette.primary_400
+              : t.atoms.border_contrast_low.borderColor,
+          },
+        ]}>
+        {completed ? (
+          <Check width={12} />
+        ) : (
+          <Text
+            style={[
+              a.font_heavy,
+              a.text_center,
+              t.atoms.text,
+              {
+                color: active
+                  ? 'white'
+                  : completed
+                  ? t.palette.primary_700
+                  : t.atoms.text_contrast_medium.color,
+                fontVariant: ['tabular-nums'],
+                width: 24,
+                height: 24,
+                lineHeight: 24,
+              },
+            ]}>
+            {index}
+          </Text>
+        )}
+      </View>
+
+      <Text
+        style={[
+          a.flex_1,
+          a.font_heavy,
+          a.text_lg,
+          a.leading_snug,
+          active ? t.atoms.text : t.atoms.text_contrast_medium,
+          {
+            top: 1,
+          },
+        ]}>
+        {title}
+      </Text>
+    </View>
+  )
+}
+
+function OptionCard({
+  option,
+  onSelect,
+}: {
+  option: ReportOption
+  onSelect?: (option: ReportOption) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const gutters = useGutters(['compact'])
+  const onPress = React.useCallback(() => {
+    onSelect?.(option)
+  }, [onSelect, option])
+  return (
+    <Button
+      label={_(msg`Create report for ${option.title}`)}
+      onPress={onPress}
+      disabled={!onSelect}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.w_full,
+            gutters,
+            a.py_sm,
+            a.rounded_sm,
+            a.border,
+            t.atoms.bg_contrast_25,
+            hovered || pressed
+              ? [t.atoms.border_contrast_high]
+              : [t.atoms.border_contrast_low],
+          ]}>
+          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+            {option.title}
+          </Text>
+          <Text
+            style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}>
+            {option.description}
+          </Text>
+        </View>
+      )}
+    </Button>
+  )
+}
+
+function OptionCardSkeleton() {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.rounded_sm,
+        a.border,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_low,
+        {height: 55}, // magic, based on web
+      ]}
+    />
+  )
+}
+
+function LabelerCard({
+  labeler,
+  onSelect,
+}: {
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+  onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const onPress = React.useCallback(() => {
+    onSelect?.(labeler)
+  }, [onSelect, labeler])
+  const title = getLabelingServiceTitle({
+    displayName: labeler.creator.displayName,
+    handle: labeler.creator.handle,
+  })
+  return (
+    <Button
+      label={_(msg`Send report to ${title}`)}
+      onPress={onPress}
+      disabled={!onSelect}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.w_full,
+            a.p_sm,
+            a.flex_row,
+            a.align_center,
+            a.gap_sm,
+            a.rounded_md,
+            a.border,
+            t.atoms.bg_contrast_25,
+            hovered || pressed
+              ? [t.atoms.border_contrast_high]
+              : [t.atoms.border_contrast_low],
+          ]}>
+          <UserAvatar
+            type="labeler"
+            size={36}
+            avatar={labeler.creator.avatar}
+          />
+          <View style={[a.flex_1]}>
+            <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+              {title}
+            </Text>
+            <Text
+              style={[
+                a.text_sm,
+                ,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              <Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans>
+            </Text>
+          </View>
+        </View>
+      )}
+    </Button>
+  )
+}
diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts
new file mode 100644
index 000000000..3f55bfb01
--- /dev/null
+++ b/src/components/moderation/ReportDialog/state.ts
@@ -0,0 +1,109 @@
+import {AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api'
+
+import {ReportOption} from './utils/useReportOptions'
+
+export type ReportState = {
+  selectedOption?: ReportOption
+  selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed
+  details?: string
+  detailsOpen: boolean
+  activeStepIndex1: number
+  error?: string
+}
+
+export type ReportAction =
+  | {
+      type: 'selectOption'
+      option: ReportOption
+    }
+  | {
+      type: 'clearOption'
+    }
+  | {
+      type: 'selectLabeler'
+      labeler: AppBskyLabelerDefs.LabelerViewDetailed
+    }
+  | {
+      type: 'clearLabeler'
+    }
+  | {
+      type: 'setDetails'
+      details: string
+    }
+  | {
+      type: 'setError'
+      error: string
+    }
+  | {
+      type: 'clearError'
+    }
+  | {
+      type: 'showDetails'
+    }
+
+export const initialState: ReportState = {
+  selectedOption: undefined,
+  selectedLabeler: undefined,
+  details: undefined,
+  detailsOpen: false,
+  activeStepIndex1: 1,
+}
+
+export function reducer(state: ReportState, action: ReportAction): ReportState {
+  switch (action.type) {
+    case 'selectOption':
+      return {
+        ...state,
+        selectedOption: action.option,
+        activeStepIndex1: 2,
+        detailsOpen:
+          !!state.details ||
+          action.option.reason === ComAtprotoModerationDefs.REASONOTHER,
+      }
+    case 'clearOption':
+      return {
+        ...state,
+        selectedOption: undefined,
+        selectedLabeler: undefined,
+        activeStepIndex1: 1,
+        detailsOpen:
+          !!state.details ||
+          state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
+      }
+    case 'selectLabeler':
+      return {
+        ...state,
+        selectedLabeler: action.labeler,
+        activeStepIndex1: 3,
+      }
+    case 'clearLabeler':
+      return {
+        ...state,
+        selectedLabeler: undefined,
+        activeStepIndex1: 2,
+        detailsOpen:
+          !!state.details ||
+          state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
+      }
+    case 'setDetails':
+      return {
+        ...state,
+        details: action.details,
+      }
+    case 'setError':
+      return {
+        ...state,
+        error: action.error,
+      }
+    case 'clearError':
+      return {
+        ...state,
+        error: undefined,
+      }
+    case 'showDetails':
+      return {
+        ...state,
+        detailsOpen: true,
+      }
+  }
+}
diff --git a/src/components/moderation/ReportDialog/types.ts b/src/components/moderation/ReportDialog/types.ts
new file mode 100644
index 000000000..444f01c66
--- /dev/null
+++ b/src/components/moderation/ReportDialog/types.ts
@@ -0,0 +1,67 @@
+import {
+  $Typed,
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyGraphDefs,
+  ChatBskyConvoDefs,
+} from '@atproto/api'
+
+import * as Dialog from '#/components/Dialog'
+
+export type ReportSubject =
+  | $Typed<AppBskyActorDefs.ProfileViewBasic>
+  | $Typed<AppBskyActorDefs.ProfileView>
+  | $Typed<AppBskyActorDefs.ProfileViewDetailed>
+  | $Typed<AppBskyGraphDefs.ListView>
+  | $Typed<AppBskyFeedDefs.GeneratorView>
+  | $Typed<AppBskyGraphDefs.StarterPackView>
+  | $Typed<AppBskyFeedDefs.PostView>
+  | {convoId: string; message: ChatBskyConvoDefs.MessageView}
+
+export type ParsedReportSubject =
+  | {
+      type: 'post'
+      uri: string
+      cid: string
+      nsid: string
+      attributes: {
+        reply: boolean
+        image: boolean
+        video: boolean
+        link: boolean
+        quote: boolean
+      }
+    }
+  | {
+      type: 'list'
+      uri: string
+      cid: string
+      nsid: string
+    }
+  | {
+      type: 'feed'
+      uri: string
+      cid: string
+      nsid: string
+    }
+  | {
+      type: 'starterPack'
+      uri: string
+      cid: string
+      nsid: string
+    }
+  | {
+      type: 'account'
+      did: string
+      nsid: string
+    }
+  | {
+      type: 'chatMessage'
+      convoId: string
+      message: ChatBskyConvoDefs.MessageView
+    }
+
+export type ReportDialogProps = {
+  control: Dialog.DialogOuterProps['control']
+  subject: ParsedReportSubject
+}
diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts
new file mode 100644
index 000000000..b79e49695
--- /dev/null
+++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts
@@ -0,0 +1,91 @@
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  AppBskyFeedPost,
+  AppBskyGraphDefs,
+} from '@atproto/api'
+
+import {
+  ParsedReportSubject,
+  ReportSubject,
+} from '#/components/moderation/ReportDialog/types'
+import * as bsky from '#/types/bsky'
+
+export function parseReportSubject(
+  subject: ReportSubject,
+): ParsedReportSubject | undefined {
+  if (!subject) return
+
+  if ('convoId' in subject) {
+    return {
+      type: 'chatMessage',
+      ...subject,
+    }
+  }
+
+  if (
+    AppBskyActorDefs.isProfileViewBasic(subject) ||
+    AppBskyActorDefs.isProfileView(subject) ||
+    AppBskyActorDefs.isProfileViewDetailed(subject)
+  ) {
+    return {
+      type: 'account',
+      did: subject.did,
+      nsid: 'app.bsky.actor.profile',
+    }
+  } else if (AppBskyGraphDefs.isListView(subject)) {
+    return {
+      type: 'list',
+      uri: subject.uri,
+      cid: subject.cid,
+      nsid: 'app.bsky.graph.list',
+    }
+  } else if (AppBskyFeedDefs.isGeneratorView(subject)) {
+    return {
+      type: 'feed',
+      uri: subject.uri,
+      cid: subject.cid,
+      nsid: 'app.bsky.feed.generator',
+    }
+  } else if (AppBskyGraphDefs.isStarterPackView(subject)) {
+    return {
+      type: 'starterPack',
+      uri: subject.uri,
+      cid: subject.cid,
+      nsid: 'app.bsky.graph.starterPack',
+    }
+  } else if (AppBskyFeedDefs.isPostView(subject)) {
+    const record = subject.record
+    const embed = bsky.post.parseEmbed(subject.embed)
+    if (
+      bsky.dangerousIsType<AppBskyFeedPost.Record>(
+        record,
+        AppBskyFeedPost.isRecord,
+      )
+    ) {
+      return {
+        type: 'post',
+        uri: subject.uri,
+        cid: subject.cid,
+        nsid: 'app.bsky.feed.post',
+        attributes: {
+          reply: !!record.reply,
+          image:
+            embed.type === 'images' ||
+            (embed.type === 'post_with_media' && embed.media.type === 'images'),
+          video:
+            embed.type === 'video' ||
+            (embed.type === 'post_with_media' && embed.media.type === 'video'),
+          link:
+            embed.type === 'link' ||
+            (embed.type === 'post_with_media' && embed.media.type === 'link'),
+          quote:
+            embed.type === 'post' ||
+            (embed.type === 'post_with_media' &&
+              (embed.view.type === 'post' ||
+                embed.view.type === 'post_with_media')),
+        },
+      }
+    }
+  }
+}
diff --git a/src/components/moderation/ReportDialog/utils/useReportOptions.ts b/src/components/moderation/ReportDialog/utils/useReportOptions.ts
new file mode 100644
index 000000000..38888d51a
--- /dev/null
+++ b/src/components/moderation/ReportDialog/utils/useReportOptions.ts
@@ -0,0 +1,121 @@
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+export interface ReportOption {
+  reason: string
+  title: string
+  description: string
+}
+
+interface ReportOptions {
+  account: ReportOption[]
+  post: ReportOption[]
+  list: ReportOption[]
+  starterPack: ReportOption[]
+  feed: ReportOption[]
+  chatMessage: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+  const {_} = useLingui()
+
+  return useMemo(() => {
+    const other = {
+      reason: ComAtprotoModerationDefs.REASONOTHER,
+      title: _(msg`Other`),
+      description: _(msg`An issue not included in these options`),
+    }
+    const common = [
+      {
+        reason: ComAtprotoModerationDefs.REASONRUDE,
+        title: _(msg`Anti-Social Behavior`),
+        description: _(msg`Harassment, trolling, or intolerance`),
+      },
+      {
+        reason: ComAtprotoModerationDefs.REASONVIOLATION,
+        title: _(msg`Illegal and Urgent`),
+        description: _(msg`Glaring violations of law or terms of service`),
+      },
+      other,
+    ]
+    return {
+      account: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Account`),
+          description: _(
+            msg`Impersonation or false claims about identity or affiliation`,
+          ),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Frequently Posts Unwanted Content`),
+          description: _(msg`Spam; excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        other,
+      ],
+      post: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Post`),
+          description: _(msg`Impersonation, misinformation, or false claims`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Nudity or adult content not labeled as such`),
+        },
+        ...common,
+      ],
+      chatMessage: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive or unwanted messages`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Inappropriate messages or explicit links`),
+        },
+        ...common,
+      ],
+      list: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      starterPack: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      feed: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+    }
+  }, [_])
+}