[🐴] Report message dialog (#3941)

* message report dialog

* report chat prompt

* typo

* 100% height sheet on android

* messages-specific report options

* restore unwanted sexual content

* chat -> conversation
zio/stable
Samuel Newman 2024-05-10 17:52:21 +01:00 committed by GitHub
parent 7370bebf07
commit ab21aafc28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 309 additions and 15 deletions

View File

@ -25,9 +25,12 @@ import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types' import {ReportDialogProps} from './types'
type ParamsWithMessages = ReportDialogProps['params'] | {type: 'message'}
export function SelectReportOptionView({ export function SelectReportOptionView({
...props ...props
}: ReportDialogProps & { }: {
params: ParamsWithMessages
labelers: AppBskyLabelerDefs.LabelerViewDetailed[] labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
onSelectReportOption: (reportOption: ReportOption) => void onSelectReportOption: (reportOption: ReportOption) => void
goBack: () => void goBack: () => void
@ -54,6 +57,9 @@ export function SelectReportOptionView({
} else if (props.params.type === 'feedgen') { } else if (props.params.type === 'feedgen') {
title = _(msg`Report this feed`) title = _(msg`Report this feed`)
description = _(msg`Why should this feed be reviewed?`) 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 { return {

View File

@ -50,6 +50,7 @@ let ConvoMenu = ({
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const leaveConvoControl = Prompt.usePromptControl() const leaveConvoControl = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const {mutate: markAsRead} = useMarkAsReadMutation() const {mutate: markAsRead} = useMarkAsReadMutation()
const {data: convo} = useConvoQuery(initialConvo) const {data: convo} = useConvoQuery(initialConvo)
@ -147,7 +148,7 @@ let ConvoMenu = ({
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
<Menu.Divider /> <Menu.Divider />
{/* TODO(samuel): implement these */} {/* TODO(samuel): implement this */}
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
label={_(msg`Block account`)} label={_(msg`Block account`)}
@ -161,11 +162,10 @@ let ConvoMenu = ({
/> />
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
label={_(msg`Report account`)} label={_(msg`Report conversation`)}
onPress={() => {}} onPress={reportControl.open}>
disabled>
<Menu.ItemText> <Menu.ItemText>
<Trans>Report account</Trans> <Trans>Report conversation</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Flag} /> <Menu.ItemIcon icon={Flag} />
</Menu.Item> </Menu.Item>
@ -194,9 +194,21 @@ let ConvoMenu = ({
confirmButtonColor="negative" confirmButtonColor="negative"
onConfirm={() => leaveConvo()} 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) ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu} export {ConvoMenu}
function noop() {}

View File

@ -193,6 +193,7 @@ let MessageItemMetadata = ({
} }
MessageItemMetadata = React.memo(MessageItemMetadata) MessageItemMetadata = React.memo(MessageItemMetadata)
export {MessageItemMetadata}
function localDateString(date: Date) { function localDateString(date: Date) {
// can't use toISOString because it should be in local time // can't use toISOString because it should be in local time

View File

@ -1,10 +1,12 @@
import React from 'react' import React from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native' import {LayoutAnimation, Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard' import * as Clipboard from 'expo-clipboard'
import {RichText} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api' import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useConvo} from 'state/messages/convo' import {useConvo} from 'state/messages/convo'
import {ConvoStatus} from 'state/messages/convo/types' import {ConvoStatus} from 'state/messages/convo/types'
@ -18,6 +20,7 @@ import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
import {MessageReportDialog} from './MessageReportDialog'
export let MessageMenu = ({ export let MessageMenu = ({
message, message,
@ -35,16 +38,22 @@ export let MessageMenu = ({
const convo = useConvo() const convo = useConvo()
const deleteControl = usePromptControl() const deleteControl = usePromptControl()
const retryDeleteControl = usePromptControl() const retryDeleteControl = usePromptControl()
const reportControl = usePromptControl()
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const onCopyPostText = React.useCallback(() => { const onCopyPostText = React.useCallback(() => {
// use when we have rich text const str = richTextToString(
// const str = richTextToString(richText, true) new RichText({
text: message.text,
facets: message.facets,
}),
true,
)
Clipboard.setStringAsync(message.text) Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`)) Toast.show(_(msg`Copied to clipboard`))
}, [_, message.text]) }, [_, message.text, message.facets])
const onDelete = React.useCallback(() => { const onDelete = React.useCallback(() => {
if (convo.status !== ConvoStatus.Ready) return if (convo.status !== ConvoStatus.Ready) return
@ -56,10 +65,6 @@ export let MessageMenu = ({
.catch(() => retryDeleteControl.open()) .catch(() => retryDeleteControl.open())
}, [_, convo, message.id, retryDeleteControl]) }, [_, convo, message.id, retryDeleteControl])
const onReport = React.useCallback(() => {
// TODO report the message
}, [])
return ( return (
<> <>
<Menu.Root control={control}> <Menu.Root control={control}>
@ -104,7 +109,7 @@ export let MessageMenu = ({
<Menu.Item <Menu.Item
testID="messageDropdownReportBtn" testID="messageDropdownReportBtn"
label={_(msg`Report message`)} label={_(msg`Report message`)}
onPress={onReport}> onPress={reportControl.open}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" /> <Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item> </Menu.Item>
@ -113,6 +118,8 @@ export let MessageMenu = ({
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<MessageReportDialog message={message} control={reportControl} />
<Prompt.Basic <Prompt.Basic
control={deleteControl} control={deleteControl}
title={_(msg`Delete message`)} title={_(msg`Delete message`)}

View File

@ -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>
)
}

View File

@ -15,6 +15,7 @@ interface ReportOptions {
list: ReportOption[] list: ReportOption[]
feedgen: ReportOption[] feedgen: ReportOption[]
other: ReportOption[] other: ReportOption[]
message: ReportOption[]
} }
export function useReportOptions(): ReportOptions { export function useReportOptions(): ReportOptions {
@ -72,6 +73,19 @@ export function useReportOptions(): ReportOptions {
}, },
...common, ...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: [ list: [
{ {
reason: ComAtprotoModerationDefs.REASONVIOLATION, reason: ComAtprotoModerationDefs.REASONVIOLATION,