about summary refs log tree commit diff
path: root/src/components/ReportDialog
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/ReportDialog')
-rw-r--r--src/components/ReportDialog/SelectReportOptionView.tsx183
-rw-r--r--src/components/ReportDialog/SubmitView.tsx262
-rw-r--r--src/components/ReportDialog/const.ts1
-rw-r--r--src/components/ReportDialog/index.tsx73
-rw-r--r--src/components/ReportDialog/types.ts15
5 files changed, 534 insertions, 0 deletions
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
new file mode 100644
index 000000000..8ae0b52ec
--- /dev/null
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -0,0 +1,183 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
+import {DMCA_LINK} from '#/components/ReportDialog/const'
+import {Link} from '#/components/Link'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {
+  Button,
+  ButtonIcon,
+  ButtonText,
+  useButtonContext,
+} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
+
+import {ReportDialogProps} from './types'
+
+export function SelectReportOptionView({
+  ...props
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  onSelectReportOption: (reportOption: ReportOption) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const allReportOptions = useReportOptions()
+  const reportOptions = allReportOptions[props.params.type]
+
+  const i18n = React.useMemo(() => {
+    let title = _(msg`Report this content`)
+    let description = _(msg`Why should this content be reviewed?`)
+
+    if (props.params.type === 'account') {
+      title = _(msg`Report this user`)
+      description = _(msg`Why should this user be reviewed?`)
+    } else if (props.params.type === 'post') {
+      title = _(msg`Report this post`)
+      description = _(msg`Why should this post be reviewed?`)
+    } else if (props.params.type === 'list') {
+      title = _(msg`Report this list`)
+      description = _(msg`Why should this list be reviewed?`)
+    } else if (props.params.type === 'feedgen') {
+      title = _(msg`Report this feed`)
+      description = _(msg`Why should this feed be reviewed?`)
+    }
+
+    return {
+      title,
+      description,
+    }
+  }, [_, props.params.type])
+
+  return (
+    <View style={[a.gap_lg]}>
+      <View style={[a.justify_center, a.gap_sm]}>
+        <Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {i18n.description}
+        </Text>
+      </View>
+
+      <Divider />
+
+      <View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
+        {reportOptions.map(reportOption => {
+          return (
+            <Button
+              key={reportOption.reason}
+              label={_(msg`Create report for ${reportOption.title}`)}
+              onPress={() => props.onSelectReportOption(reportOption)}>
+              <ReportOptionButton
+                title={reportOption.title}
+                description={reportOption.description}
+              />
+            </Button>
+          )
+        })}
+
+        {(props.params.type === 'post' || props.params.type === 'account') && (
+          <View style={[a.pt_md, a.px_md]}>
+            <View
+              style={[
+                a.flex_row,
+                a.align_center,
+                a.justify_between,
+                a.gap_lg,
+                a.p_md,
+                a.pl_lg,
+                a.rounded_md,
+                t.atoms.bg_contrast_900,
+              ]}>
+              <Text
+                style={[
+                  a.flex_1,
+                  t.atoms.text_inverted,
+                  a.italic,
+                  a.leading_snug,
+                ]}>
+                <Trans>Need to report a copyright violation?</Trans>
+              </Text>
+              <Link
+                to={DMCA_LINK}
+                label={_(msg`View details for reporting a copyright violation`)}
+                size="small"
+                variant="solid"
+                color="secondary">
+                <ButtonText>
+                  <Trans>View details</Trans>
+                </ButtonText>
+                <ButtonIcon position="right" icon={SquareArrowTopRight} />
+              </Link>
+            </View>
+          </View>
+        )}
+      </View>
+    </View>
+  )
+}
+
+function ReportOptionButton({
+  title,
+  description,
+}: {
+  title: string
+  description: string
+}) {
+  const t = useTheme()
+  const {hovered, pressed} = useButtonContext()
+  const interacted = hovered || pressed
+
+  const styles = React.useMemo(() => {
+    return {
+      interacted: {
+        backgroundColor: t.palette.contrast_50,
+      },
+    }
+  }, [t])
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.p_md,
+        a.rounded_md,
+        {paddingRight: 70},
+        interacted && styles.interacted,
+      ]}>
+      <View style={[a.flex_1, a.gap_xs]}>
+        <Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
+          {title}
+        </Text>
+        <Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
+      </View>
+
+      <View
+        style={[
+          a.absolute,
+          a.inset_0,
+          a.justify_center,
+          a.pr_md,
+          {left: 'auto'},
+        ]}>
+        <ChevronRight
+          size="md"
+          fill={
+            hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
+          }
+        />
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
new file mode 100644
index 000000000..99af64a2a
--- /dev/null
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -0,0 +1,262 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {AppBskyLabelerDefs} from '@atproto/api'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+
+import {atoms as a, useTheme, native} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import * as Toggle from '#/components/forms/Toggle'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {Loader} from '#/components/Loader'
+import * as Toast from '#/view/com/util/Toast'
+
+import {ReportDialogProps} from './types'
+import {getAgent} from '#/state/session'
+
+export function SubmitView({
+  params,
+  labelers,
+  selectedReportOption,
+  goBack,
+  onSubmitComplete,
+}: ReportDialogProps & {
+  labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
+  selectedReportOption: ReportOption
+  goBack: () => void
+  onSubmitComplete: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const [details, setDetails] = React.useState<string>('')
+  const [submitting, setSubmitting] = React.useState<boolean>(false)
+  const [selectedServices, setSelectedServices] = React.useState<string[]>(
+    labelers?.map(labeler => labeler.creator.did) || [],
+  )
+  const [error, setError] = React.useState('')
+
+  const submit = React.useCallback(async () => {
+    setSubmitting(true)
+    setError('')
+
+    const $type =
+      params.type === 'account'
+        ? 'com.atproto.admin.defs#repoRef'
+        : 'com.atproto.repo.strongRef'
+    const report = {
+      reasonType: selectedReportOption.reason,
+      subject: {
+        $type,
+        ...params,
+      },
+      reason: details,
+    }
+    const results = await Promise.all(
+      selectedServices.map(did =>
+        getAgent()
+          .withProxy('atproto_labeler', did)
+          .createModerationReport(report)
+          .then(
+            _ => true,
+            _ => false,
+          ),
+      ),
+    )
+
+    setSubmitting(false)
+
+    if (results.includes(true)) {
+      Toast.show(_(msg`Thank you. Your report has been sent.`))
+      onSubmitComplete()
+    } else {
+      setError(
+        _(
+          msg`There was an issue sending your report. Please check your internet connection.`,
+        ),
+      )
+    }
+  }, [
+    _,
+    params,
+    details,
+    selectedReportOption,
+    selectedServices,
+    onSubmitComplete,
+    setError,
+  ])
+
+  return (
+    <View style={[a.gap_2xl]}>
+      <Button
+        size="small"
+        variant="solid"
+        color="secondary"
+        shape="round"
+        label={_(msg`Go back to previous step`)}
+        onPress={goBack}>
+        <ButtonIcon icon={ChevronLeft} />
+      </Button>
+
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.p_md,
+          a.rounded_md,
+          a.border,
+          t.atoms.border_contrast_low,
+        ]}>
+        <View style={[a.flex_1, a.gap_xs]}>
+          <Text style={[a.text_md, a.font_bold]}>
+            {selectedReportOption.title}
+          </Text>
+          <Text style={[a.leading_tight, {maxWidth: 400}]}>
+            {selectedReportOption.description}
+          </Text>
+        </View>
+
+        <Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
+      </View>
+
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Select the moderation service(s) to report to</Trans>
+        </Text>
+
+        <Toggle.Group
+          label="Select mod services"
+          values={selectedServices}
+          onChange={setSelectedServices}>
+          <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
+            {labelers.map(labeler => {
+              const title = getLabelingServiceTitle({
+                displayName: labeler.creator.displayName,
+                handle: labeler.creator.handle,
+              })
+              return (
+                <Toggle.Item
+                  key={labeler.creator.did}
+                  name={labeler.creator.did}
+                  label={title}>
+                  <LabelerToggle title={title} />
+                </Toggle.Item>
+              )
+            })}
+          </View>
+        </Toggle.Group>
+      </View>
+      <View style={[a.gap_md]}>
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>Optionally provide additional information below:</Trans>
+        </Text>
+
+        <View style={[a.relative, a.w_full]}>
+          <Dialog.Input
+            multiline
+            value={details}
+            onChangeText={setDetails}
+            label="Text field"
+            style={{paddingRight: 60}}
+            numberOfLines={6}
+          />
+
+          <View
+            style={[
+              a.absolute,
+              a.flex_row,
+              a.align_center,
+              a.pr_md,
+              a.pb_sm,
+              {
+                bottom: 0,
+                right: 0,
+              },
+            ]}>
+            <CharProgress count={details?.length || 0} />
+          </View>
+        </View>
+      </View>
+
+      <View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
+        {!selectedServices.length ||
+          (error && (
+            <Text
+              style={[
+                a.flex_1,
+                a.italic,
+                a.leading_snug,
+                t.atoms.text_contrast_medium,
+              ]}>
+              {error ? (
+                error
+              ) : (
+                <Trans>You must select at least one labeler for a report</Trans>
+              )}
+            </Text>
+          ))}
+
+        <Button
+          size="large"
+          variant="solid"
+          color="negative"
+          label={_(msg`Send report`)}
+          onPress={submit}
+          disabled={!selectedServices.length}>
+          <ButtonText>
+            <Trans>Send report</Trans>
+          </ButtonText>
+          {submitting && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+function LabelerToggle({title}: {title: string}) {
+  const t = useTheme()
+  const ctx = Toggle.useItemContext()
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_md,
+        a.p_md,
+        a.pr_lg,
+        a.rounded_sm,
+        a.overflow_hidden,
+        t.atoms.bg_contrast_25,
+        ctx.selected && [t.atoms.bg_contrast_50],
+      ]}>
+      <Toggle.Checkbox />
+      <View
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.justify_between,
+          a.gap_lg,
+          a.z_10,
+        ]}>
+        <Text
+          style={[
+            native({marginTop: 2}),
+            t.atoms.text_contrast_medium,
+            ctx.selected && t.atoms.text,
+          ]}>
+          {title}
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts
new file mode 100644
index 000000000..30c9aff88
--- /dev/null
+++ b/src/components/ReportDialog/const.ts
@@ -0,0 +1 @@
+export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx
new file mode 100644
index 000000000..b35727c7d
--- /dev/null
+++ b/src/components/ReportDialog/index.tsx
@@ -0,0 +1,73 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {useMyLabelersQuery} from '#/state/queries/preferences'
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
+
+import {atoms as a} from '#/alf'
+import {Loader} from '#/components/Loader'
+import * as Dialog from '#/components/Dialog'
+import {Text} from '#/components/Typography'
+
+import {ReportDialogProps} from './types'
+import {SelectReportOptionView} from './SelectReportOptionView'
+import {SubmitView} from './SubmitView'
+import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
+
+export function ReportDialog(props: ReportDialogProps) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <Dialog.Handle />
+
+      <ReportDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ReportDialogInner(props: ReportDialogProps) {
+  const {
+    isLoading: isLabelerLoading,
+    data: labelers,
+    error,
+  } = useMyLabelersQuery()
+  const isLoading = useDelayedLoading(500, isLabelerLoading)
+  const [selectedReportOption, setSelectedReportOption] = React.useState<
+    ReportOption | undefined
+  >()
+
+  return (
+    <Dialog.ScrollableInner label="Report Dialog">
+      {isLoading ? (
+        <View style={[a.align_center, {height: 100}]}>
+          <Loader size="xl" />
+          {/* Here to capture focus for a hot sec to prevent flash */}
+          <Pressable accessible={false} />
+        </View>
+      ) : error || !labelers ? (
+        <View>
+          <Text style={[a.text_md]}>
+            <Trans>Something went wrong, please try again.</Trans>
+          </Text>
+        </View>
+      ) : selectedReportOption ? (
+        <SubmitView
+          {...props}
+          labelers={labelers}
+          selectedReportOption={selectedReportOption}
+          goBack={() => setSelectedReportOption(undefined)}
+          onSubmitComplete={() => props.control.close()}
+        />
+      ) : (
+        <SelectReportOptionView
+          {...props}
+          labelers={labelers}
+          onSelectReportOption={setSelectedReportOption}
+        />
+      )}
+
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
new file mode 100644
index 000000000..0c8a1e077
--- /dev/null
+++ b/src/components/ReportDialog/types.ts
@@ -0,0 +1,15 @@
+import * as Dialog from '#/components/Dialog'
+
+export type ReportDialogProps = {
+  control: Dialog.DialogOuterProps['control']
+  params:
+    | {
+        type: 'post' | 'list' | 'feedgen' | 'other'
+        uri: string
+        cid: string
+      }
+    | {
+        type: 'account'
+        did: string
+      }
+}