From ab21aafc281c04c223828b3a2436b02a98115bc7 Mon Sep 17 00:00:00 2001
From: Samuel Newman <mozzius@protonmail.com>
Date: Fri, 10 May 2024 17:52:21 +0100
Subject: [PATCH] =?UTF-8?q?[=F0=9F=90=B4]=20Report=20message=20dialog=20(#?=
 =?UTF-8?q?3941)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* message report dialog

* report chat prompt

* typo

* 100% height sheet on android

* messages-specific report options

* restore unwanted sexual content

* chat -> conversation
---
 .../ReportDialog/SelectReportOptionView.tsx   |   8 +-
 src/components/dms/ConvoMenu.tsx              |  22 +-
 src/components/dms/MessageItem.tsx            |   1 +
 src/components/dms/MessageMenu.tsx            |  25 +-
 src/components/dms/MessageReportDialog.tsx    | 254 ++++++++++++++++++
 src/lib/moderation/useReportOptions.ts        |  14 +
 6 files changed, 309 insertions(+), 15 deletions(-)
 create mode 100644 src/components/dms/MessageReportDialog.tsx

diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
index 8219b20951..da3c434401 100644
--- a/src/components/ReportDialog/SelectReportOptionView.tsx
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -25,9 +25,12 @@ import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from
 import {Text} from '#/components/Typography'
 import {ReportDialogProps} from './types'
 
+type ParamsWithMessages = ReportDialogProps['params'] | {type: 'message'}
+
 export function SelectReportOptionView({
   ...props
-}: ReportDialogProps & {
+}: {
+  params: ParamsWithMessages
   labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
   onSelectReportOption: (reportOption: ReportOption) => void
   goBack: () => void
@@ -54,6 +57,9 @@ export function SelectReportOptionView({
     } else if (props.params.type === 'feedgen') {
       title = _(msg`Report this feed`)
       description = _(msg`Why should this feed be reviewed?`)
+    } else if (props.params.type === 'message') {
+      title = _(msg`Report this message`)
+      description = _(msg`Why should this message be reviewed?`)
     }
 
     return {
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 8c8e7ed486..68d8150747 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -50,6 +50,7 @@ let ConvoMenu = ({
   const {_} = useLingui()
   const t = useTheme()
   const leaveConvoControl = Prompt.usePromptControl()
+  const reportControl = Prompt.usePromptControl()
   const {mutate: markAsRead} = useMarkAsReadMutation()
 
   const {data: convo} = useConvoQuery(initialConvo)
@@ -147,7 +148,7 @@ let ConvoMenu = ({
             </Menu.Item>
           </Menu.Group>
           <Menu.Divider />
-          {/* TODO(samuel): implement these */}
+          {/* TODO(samuel): implement this */}
           <Menu.Group>
             <Menu.Item
               label={_(msg`Block account`)}
@@ -161,11 +162,10 @@ let ConvoMenu = ({
               />
             </Menu.Item>
             <Menu.Item
-              label={_(msg`Report account`)}
-              onPress={() => {}}
-              disabled>
+              label={_(msg`Report conversation`)}
+              onPress={reportControl.open}>
               <Menu.ItemText>
-                <Trans>Report account</Trans>
+                <Trans>Report conversation</Trans>
               </Menu.ItemText>
               <Menu.ItemIcon icon={Flag} />
             </Menu.Item>
@@ -194,9 +194,21 @@ let ConvoMenu = ({
         confirmButtonColor="negative"
         onConfirm={() => leaveConvo()}
       />
+
+      <Prompt.Basic
+        control={reportControl}
+        title={_(msg`Report conversation`)}
+        description={_(
+          msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
+        )}
+        confirmButtonCta={_(msg`I understand`)}
+        onConfirm={noop}
+      />
     </>
   )
 }
 ConvoMenu = React.memo(ConvoMenu)
 
 export {ConvoMenu}
+
+function noop() {}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index faf0c88cd8..e162e40ee8 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -193,6 +193,7 @@ let MessageItemMetadata = ({
 }
 
 MessageItemMetadata = React.memo(MessageItemMetadata)
+export {MessageItemMetadata}
 
 function localDateString(date: Date) {
   // can't use toISOString because it should be in local time
diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx
index 75807f8187..c6abd51106 100644
--- a/src/components/dms/MessageMenu.tsx
+++ b/src/components/dms/MessageMenu.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
 import {LayoutAnimation, Pressable, View} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
+import {RichText} from '@atproto/api'
 import {ChatBskyConvoDefs} from '@atproto-labs/api'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {isWeb} from 'platform/detection'
 import {useConvo} from 'state/messages/convo'
 import {ConvoStatus} from 'state/messages/convo/types'
@@ -18,6 +20,7 @@ import * as Menu from '#/components/Menu'
 import * as Prompt from '#/components/Prompt'
 import {usePromptControl} from '#/components/Prompt'
 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
+import {MessageReportDialog} from './MessageReportDialog'
 
 export let MessageMenu = ({
   message,
@@ -35,16 +38,22 @@ export let MessageMenu = ({
   const convo = useConvo()
   const deleteControl = usePromptControl()
   const retryDeleteControl = usePromptControl()
+  const reportControl = usePromptControl()
 
   const isFromSelf = message.sender?.did === currentAccount?.did
 
   const onCopyPostText = React.useCallback(() => {
-    // use when we have rich text
-    // const str = richTextToString(richText, true)
+    const str = richTextToString(
+      new RichText({
+        text: message.text,
+        facets: message.facets,
+      }),
+      true,
+    )
 
-    Clipboard.setStringAsync(message.text)
+    Clipboard.setStringAsync(str)
     Toast.show(_(msg`Copied to clipboard`))
-  }, [_, message.text])
+  }, [_, message.text, message.facets])
 
   const onDelete = React.useCallback(() => {
     if (convo.status !== ConvoStatus.Ready) return
@@ -56,10 +65,6 @@ export let MessageMenu = ({
       .catch(() => retryDeleteControl.open())
   }, [_, convo, message.id, retryDeleteControl])
 
-  const onReport = React.useCallback(() => {
-    // TODO report the message
-  }, [])
-
   return (
     <>
       <Menu.Root control={control}>
@@ -104,7 +109,7 @@ export let MessageMenu = ({
               <Menu.Item
                 testID="messageDropdownReportBtn"
                 label={_(msg`Report message`)}
-                onPress={onReport}>
+                onPress={reportControl.open}>
                 <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
                 <Menu.ItemIcon icon={Warning} position="right" />
               </Menu.Item>
@@ -113,6 +118,8 @@ export let MessageMenu = ({
         </Menu.Outer>
       </Menu.Root>
 
+      <MessageReportDialog message={message} control={reportControl} />
+
       <Prompt.Basic
         control={deleteControl}
         title={_(msg`Delete message`)}
diff --git a/src/components/dms/MessageReportDialog.tsx b/src/components/dms/MessageReportDialog.tsx
new file mode 100644
index 0000000000..eedb12440e
--- /dev/null
+++ b/src/components/dms/MessageReportDialog.tsx
@@ -0,0 +1,254 @@
+import React, {memo, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {RichText as RichTextAPI} from '@atproto/api'
+import {
+  ChatBskyConvoDefs,
+  ComAtprotoModerationCreateReport,
+} from '@atproto-labs/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation} from '@tanstack/react-query'
+
+import {ReportOption} from '#/lib/moderation/useReportOptions'
+import {isAndroid} from '#/platform/detection'
+import {useAgent} from '#/state/session'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {Button, ButtonIcon, ButtonText} from '../Button'
+import {Divider} from '../Divider'
+import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
+import {Loader} from '../Loader'
+import {SelectReportOptionView} from '../ReportDialog/SelectReportOptionView'
+import {RichText} from '../RichText'
+import {Text} from '../Typography'
+import {MessageItemMetadata} from './MessageItem'
+
+let MessageReportDialog = ({
+  control,
+  message,
+}: {
+  control: Dialog.DialogControlProps
+  message: ChatBskyConvoDefs.MessageView
+}): React.ReactNode => {
+  const {_} = useLingui()
+  return (
+    <Dialog.Outer
+      control={control}
+      nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner label={_(msg`Report this message`)}>
+        <DialogInner message={message} />
+        <Dialog.Close />
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
+MessageReportDialog = memo(MessageReportDialog)
+export {MessageReportDialog}
+
+function DialogInner({message}: {message: ChatBskyConvoDefs.MessageView}) {
+  const [reportOption, setReportOption] = useState<ReportOption | null>(null)
+
+  return reportOption ? (
+    <SubmitStep
+      message={message}
+      reportOption={reportOption}
+      goBack={() => setReportOption(null)}
+    />
+  ) : (
+    <ReasonStep setReportOption={setReportOption} />
+  )
+}
+
+function ReasonStep({
+  setReportOption,
+}: {
+  setReportOption: (reportOption: ReportOption) => void
+}) {
+  const control = Dialog.useDialogContext()
+
+  return (
+    <SelectReportOptionView
+      labelers={[]}
+      goBack={control.close}
+      params={{type: 'message'}}
+      onSelectReportOption={setReportOption}
+    />
+  )
+}
+
+function SubmitStep({
+  message,
+  reportOption,
+  goBack,
+}: {
+  message: ChatBskyConvoDefs.MessageView
+  reportOption: ReportOption
+  goBack: () => void
+}) {
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  const [details, setDetails] = useState('')
+  const control = Dialog.useDialogContext()
+  const {getAgent} = useAgent()
+
+  const {
+    mutate: submit,
+    error,
+    isPending: submitting,
+  } = useMutation({
+    mutationFn: async () => {
+      const report = {
+        reasonType: reportOption.reason,
+        subject: {
+          $type: 'chat.bsky.convo.defs#messageRef',
+          messageId: message.id,
+          did: message.sender!.did,
+        } satisfies ChatBskyConvoDefs.MessageRef,
+        reason: details,
+      } satisfies ComAtprotoModerationCreateReport.InputSchema
+
+      await getAgent().createModerationReport(report)
+    },
+    onSuccess: () => {
+      control.close(() => {
+        Toast.show(_(msg`Thank you. Your report has been sent.`))
+      })
+    },
+  })
+
+  return (
+    <View style={a.gap_lg}>
+      <Button
+        size="small"
+        variant="solid"
+        color="secondary"
+        shape="round"
+        label={_(msg`Go back to previous step`)}
+        onPress={goBack}>
+        <ButtonIcon icon={Chevron} />
+      </Button>
+
+      <View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
+        <Text style={[a.text_2xl, a.font_bold]}>
+          <Trans>Report this message</Trans>
+        </Text>
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Your report will be sent to the Bluesky Moderation Service
+          </Trans>
+        </Text>
+      </View>
+
+      <PreviewMessage message={message} />
+
+      <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+        <Trans>Reason: {reportOption.title}</Trans>
+      </Text>
+
+      <Divider />
+
+      <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]}>
+        {error && (
+          <Text
+            style={[
+              a.flex_1,
+              a.italic,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>
+              There was an issue sending your report. Please check your internet
+              connection.
+            </Trans>
+          </Text>
+        )}
+
+        <Button
+          testID="sendReportBtn"
+          size="large"
+          variant="solid"
+          color="negative"
+          label={_(msg`Send report`)}
+          onPress={() => submit()}>
+          <ButtonText>
+            <Trans>Send report</Trans>
+          </ButtonText>
+          {submitting && <ButtonIcon icon={Loader} />}
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) {
+  const t = useTheme()
+  const rt = useMemo(() => {
+    return new RichTextAPI({text: message.text, facets: message.facets})
+  }, [message.text, message.facets])
+
+  return (
+    <View style={a.align_start}>
+      <View
+        style={[
+          a.py_sm,
+          a.my_2xs,
+          a.rounded_md,
+          {
+            paddingLeft: 14,
+            paddingRight: 14,
+            backgroundColor: t.palette.contrast_50,
+            borderRadius: 17,
+          },
+          {borderBottomLeftRadius: 2},
+        ]}>
+        <RichText
+          value={rt}
+          style={[a.text_md, a.leading_snug]}
+          interactiveStyle={a.underline}
+          enableTags
+        />
+      </View>
+      <MessageItemMetadata
+        message={message}
+        isLastInGroup
+        style={[a.text_left, a.mb_0]}
+      />
+    </View>
+  )
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
index a22386b991..c96f302a63 100644
--- a/src/lib/moderation/useReportOptions.ts
+++ b/src/lib/moderation/useReportOptions.ts
@@ -15,6 +15,7 @@ interface ReportOptions {
   list: ReportOption[]
   feedgen: ReportOption[]
   other: ReportOption[]
+  message: ReportOption[]
 }
 
 export function useReportOptions(): ReportOptions {
@@ -72,6 +73,19 @@ export function useReportOptions(): ReportOptions {
         },
         ...common,
       ],
+      message: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive or unwanted messages`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Unwanted sexual content`),
+        },
+        ...common,
+      ],
       list: [
         {
           reason: ComAtprotoModerationDefs.REASONVIOLATION,