From f43950929a9ac5c7a2aac86d479629152212181d Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 3 Nov 2025 10:46:15 -0600 Subject: [PATCH] [Reporting] Integrate new `ReportDialog` into DMs (#9213) * Update types, fork copy for convo vs message * Integrate in remaining spots * Replace DoneStep with separate dialog that opens after reporting * Handle profile error state by just showing a success message * Delete old dms ReportDialog * Chats only go to bsky * Only open block or delete dialog if submitted successfully * make close button always large * prevent expansion of blockordelete dialog * Delete old report dialog (#9214)e * Skip labeler selection for Bluesky-only cases * Rename AfterReportDialog --------- Co-authored-by: Samuel Newman --- .../ReportDialog/SelectLabelerView.tsx | 85 ---- .../ReportDialog/SelectReportOptionView.tsx | 195 -------- src/components/ReportDialog/SubmitView.tsx | 274 ----------- src/components/ReportDialog/const.ts | 1 - src/components/ReportDialog/index.tsx | 97 ---- src/components/ReportDialog/types.ts | 16 - src/components/dms/AfterReportDialog.tsx | 219 +++++++++ src/components/dms/ConvoMenu.tsx | 34 +- src/components/dms/MessageContextMenu.tsx | 23 +- src/components/dms/ReportDialog.tsx | 444 ------------------ .../moderation/ReportDialog/action.ts | 2 +- .../moderation/ReportDialog/copy.ts | 18 +- .../moderation/ReportDialog/index.tsx | 199 ++++---- .../moderation/ReportDialog/types.ts | 20 +- .../ReportDialog/utils/parseReportSubject.ts | 2 +- .../Messages/components/RequestButtons.tsx | 34 +- 16 files changed, 436 insertions(+), 1227 deletions(-) delete mode 100644 src/components/ReportDialog/SelectLabelerView.tsx delete mode 100644 src/components/ReportDialog/SelectReportOptionView.tsx delete mode 100644 src/components/ReportDialog/SubmitView.tsx delete mode 100644 src/components/ReportDialog/const.ts delete mode 100644 src/components/ReportDialog/index.tsx delete mode 100644 src/components/ReportDialog/types.ts create mode 100644 src/components/dms/AfterReportDialog.tsx delete mode 100644 src/components/dms/ReportDialog.tsx diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx deleted file mode 100644 index 02b3244428..0000000000 --- a/src/components/ReportDialog/SelectLabelerView.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import {View} from 'react-native' -import {type AppBskyLabelerDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {getLabelingServiceTitle} from '#/lib/moderation' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, useButtonContext} from '#/components/Button' -import {Divider} from '#/components/Divider' -import * as LabelingServiceCard from '#/components/LabelingServiceCard' -import {Text} from '#/components/Typography' -import {type ReportDialogProps} from './types' - -export function SelectLabelerView({ - ...props -}: ReportDialogProps & { - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] - onSelectLabeler: (v: string) => void -}) { - const t = useTheme() - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - - return ( - - - - Select moderator - - - To whom would you like to send this report? - - - - - - - {props.labelers.map(labeler => { - return ( - - ) - })} - - - ) -} - -function LabelerButton({ - labeler, -}: { - labeler: AppBskyLabelerDefs.LabelerViewDetailed -}) { - const t = useTheme() - const {hovered, pressed} = useButtonContext() - const interacted = hovered || pressed - - return ( - - - - - - @{labeler.creator.handle} - - - - ) -} diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx deleted file mode 100644 index 0fd321cdf8..0000000000 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {type AppBskyLabelerDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import { - type ReportOption, - useReportOptions, -} from '#/lib/moderation/useReportOptions' -import {Link} from '#/components/Link' -import {DMCA_LINK} from '#/components/ReportDialog/const' -export {useDialogControl as useReportDialogControl} from '#/components/Dialog' - -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import { - Button, - ButtonIcon, - ButtonText, - useButtonContext, -} from '#/components/Button' -import {Divider} from '#/components/Divider' -import { - ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, - ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, -} from '#/components/icons/Chevron' -import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' -import {Text} from '#/components/Typography' -import {type ReportDialogProps} from './types' - -export function SelectReportOptionView(props: { - params: ReportDialogProps['params'] - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] - onSelectReportOption: (reportOption: ReportOption) => void - goBack: () => void -}) { - const t = useTheme() - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - 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?`) - } else if (props.params.type === 'starterpack') { - title = _(msg`Report this starter pack`) - description = _(msg`Why should this starter pack be reviewed?`) - } else if (props.params.type === 'convoMessage') { - title = _(msg`Report this message`) - description = _(msg`Why should this message be reviewed?`) - } - - return { - title, - description, - } - }, [_, props.params.type]) - - return ( - - {props.labelers?.length > 1 ? ( - - ) : null} - - - {i18n.title} - - {i18n.description} - - - - - - - {reportOptions.map(reportOption => { - return ( - - ) - })} - - {(props.params.type === 'post' || props.params.type === 'account') && ( - - - Need to report a copyright violation? - - - - View details - - - - - )} - - - ) -} - -function ReportOptionButton({ - title, - description, -}: { - title: string - description: string -}) { - const t = useTheme() - const {hovered, pressed} = useButtonContext() - const interacted = hovered || pressed - - return ( - - - - {title} - - {description} - - - - - - - ) -} diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx deleted file mode 100644 index 3dbb1389d2..0000000000 --- a/src/components/ReportDialog/SubmitView.tsx +++ /dev/null @@ -1,274 +0,0 @@ -import React from 'react' -import {View} from 'react-native' -import {type AppBskyLabelerDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {getLabelingServiceTitle} from '#/lib/moderation' -import {type 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, native, useTheme} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import * as Toggle from '#/components/forms/Toggle' -import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' -import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' -import {PaperPlane_Stroke2_Corner0_Rounded as SendIcon} from '#/components/icons/PaperPlane' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {type ReportDialogProps} from './types' - -export function SubmitView({ - params, - labelers, - selectedLabeler, - selectedReportOption, - goBack, - onSubmitComplete, -}: ReportDialogProps & { - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] - selectedLabeler: string - selectedReportOption: ReportOption - goBack: () => void - onSubmitComplete: () => void -}) { - const t = useTheme() - const {_} = useLingui() - const agent = useAgent() - const [details, setDetails] = React.useState('') - const [submitting, setSubmitting] = React.useState(false) - const [selectedServices, setSelectedServices] = React.useState([ - selectedLabeler, - ]) - 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 => { - return agent - .createModerationReport(report, { - encoding: 'application/json', - headers: { - 'atproto-proxy': `${did}#atproto_labeler`, - }, - }) - .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, - agent, - ]) - - return ( - - - - - - - {selectedReportOption.title} - - - {selectedReportOption.description} - - - - - - - - - Select the moderation service(s) to report to - - - - - {labelers.map(labeler => { - const title = getLabelingServiceTitle({ - displayName: labeler.creator.displayName, - handle: labeler.creator.handle, - }) - return ( - - - - ) - })} - - - - - - Optionally provide additional information below: - - - - - - - - - - - - - {!selectedServices.length || - (error && ( - - {error ? ( - error - ) : ( - You must select at least one labeler for a report - )} - - ))} - - - - {/* Maybe fix this later -h */} - {isAndroid ? : null} - - ) -} - -function LabelerToggle({title}: {title: string}) { - const t = useTheme() - const ctx = Toggle.useItemContext() - - return ( - - - - - {title} - - - - ) -} diff --git a/src/components/ReportDialog/const.ts b/src/components/ReportDialog/const.ts deleted file mode 100644 index 30c9aff88d..0000000000 --- a/src/components/ReportDialog/const.ts +++ /dev/null @@ -1 +0,0 @@ -export const DMCA_LINK = 'https://bsky.social/about/support/copyright' diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx deleted file mode 100644 index 2401ab89f5..0000000000 --- a/src/components/ReportDialog/index.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react' -import {Pressable, View} from 'react-native' -import {type ScrollView} from 'react-native-gesture-handler' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {type ReportOption} from '#/lib/moderation/useReportOptions' -import {useMyLabelersQuery} from '#/state/queries/preferences' -export {useDialogControl as useReportDialogControl} from '#/components/Dialog' - -import {type AppBskyLabelerDefs} from '@atproto/api' - -import {atoms as a} from '#/alf' -import * as Dialog from '#/components/Dialog' -import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {SelectLabelerView} from './SelectLabelerView' -import {SelectReportOptionView} from './SelectReportOptionView' -import {SubmitView} from './SubmitView' -import {type ReportDialogProps} from './types' - -export function ReportDialog(props: ReportDialogProps) { - return ( - - - - - ) -} - -function ReportDialogInner(props: ReportDialogProps) { - const {_} = useLingui() - const { - isLoading: isLabelerLoading, - data: labelers, - error, - } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) - const isLoading = useDelayedLoading(500, isLabelerLoading) - - const ref = React.useRef(null) - - return ( - - {isLoading ? ( - - - {/* Here to capture focus for a hot sec to prevent flash */} - - - ) : error || !labelers ? ( - - - Something went wrong, please try again. - - - ) : ( - - )} - - ) -} - -function ReportDialogLoaded( - props: ReportDialogProps & { - labelers: AppBskyLabelerDefs.LabelerViewDetailed[] - }, -) { - const [selectedLabeler, setSelectedLabeler] = React.useState< - string | undefined - >(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined) - const [selectedReportOption, setSelectedReportOption] = React.useState< - ReportOption | undefined - >() - - if (selectedReportOption && selectedLabeler) { - return ( - setSelectedReportOption(undefined)} - onSubmitComplete={() => props.control.close()} - /> - ) - } - if (selectedLabeler) { - return ( - setSelectedLabeler(undefined)} - onSelectReportOption={setSelectedReportOption} - /> - ) - } - return -} diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts deleted file mode 100644 index ac9a18d6dd..0000000000 --- a/src/components/ReportDialog/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type * as Dialog from '#/components/Dialog' - -export type ReportDialogProps = { - control: Dialog.DialogOuterProps['control'] - params: - | { - type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other' - uri: string - cid: string - } - | { - type: 'account' - did: string - } - | {type: 'convoMessage'} -} diff --git a/src/components/dms/AfterReportDialog.tsx b/src/components/dms/AfterReportDialog.tsx new file mode 100644 index 0000000000..a87e5bdc25 --- /dev/null +++ b/src/components/dms/AfterReportDialog.tsx @@ -0,0 +1,219 @@ +import {memo, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {StackActions, useNavigation} from '@react-navigation/native' +import type React from 'react' + +import {type NavigationProp} from '#/lib/routes/types' +import {isNative} from '#/platform/detection' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + useProfileBlockMutationQueue, + useProfileQuery, +} from '#/state/queries/profile' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import * as Toggle from '#/components/forms/Toggle' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +type ReportDialogParams = { + convoId: string + message: ChatBskyConvoDefs.MessageView +} + +/** + * Dialog shown after a report is submitted, allowing the user to block the + * reporter and/or leave the conversation. + */ +export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({ + control, + params, + currentScreen, +}: { + control: Dialog.DialogControlProps + params: ReportDialogParams + currentScreen: 'list' | 'conversation' +}): React.ReactNode { + const {_} = useLingui() + return ( + + + + + + + + ) +}) + +function DialogInner({ + params, + currentScreen, +}: { + params: ReportDialogParams + currentScreen: 'list' | 'conversation' +}) { + const t = useTheme() + const {_} = useLingui() + const control = Dialog.useDialogContext() + const { + data: profile, + isLoading, + isError, + } = useProfileQuery({ + did: params.message.sender.did, + }) + + return isLoading ? ( + + + + ) : isError || !profile ? ( + + + + Report submitted + + + Our moderation team has received your report. + + + + + + ) : ( + + ) +} + +function DoneStep({ + convoId, + currentScreen, + profile, +}: { + convoId: string + currentScreen: 'list' | 'conversation' + profile: AppBskyActorDefs.ProfileViewDetailed +}) { + const {_} = useLingui() + const navigation = useNavigation() + const control = Dialog.useDialogContext() + const {gtMobile} = useBreakpoints() + const t = useTheme() + const [actions, setActions] = useState(['block', 'leave']) + const shadow = useProfileShadow(profile) + const [queueBlock] = useProfileBlockMutationQueue(shadow) + + const {mutate: leaveConvo} = useLeaveConvo(convoId, { + onMutate: () => { + if (currentScreen === 'conversation') { + navigation.dispatch( + StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}), + ) + } + }, + onError: () => { + Toast.show(_(msg`Could not leave chat`), 'xmark') + }, + }) + + let btnText = _(msg`Done`) + let toastMsg: string | undefined + if (actions.includes('leave') && actions.includes('block')) { + btnText = _(msg`Block and Delete`) + toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) + } else if (actions.includes('leave')) { + btnText = _(msg`Delete Conversation`) + toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) + } else if (actions.includes('block')) { + btnText = _(msg`Block User`) + toastMsg = _(msg({message: 'User blocked', context: 'toast'})) + } + + const onPressPrimaryAction = () => { + control.close(() => { + if (actions.includes('block')) { + queueBlock() + } + if (actions.includes('leave')) { + leaveConvo() + } + if (toastMsg) { + Toast.show(toastMsg, 'check') + } + }) + } + + return ( + + + + Report submitted + + + Our moderation team has received your report. + + + + + + + + Block user + + + + + + Delete conversation + + + + + + + + + + + ) +} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 1b1ebbcd59..fa50ec1e99 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -17,10 +17,10 @@ import * as Toast from '#/view/com/util/Toast' import {type ViewStyleProp} from '#/alf' import {atoms as a} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' +import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' -import {ReportDialog} from '#/components/dms/ReportDialog' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' @@ -33,6 +33,7 @@ import { } from '#/components/icons/Person' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import * as Menu from '#/components/Menu' +import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import type * as bsky from '#/types/bsky' @@ -65,6 +66,7 @@ let ConvoMenu = ({ const leaveConvoControl = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() const blockedByListControl = Prompt.usePromptControl() + const blockOrDeleteControl = Prompt.usePromptControl() const {listBlocks} = blockInfo @@ -113,15 +115,27 @@ let ConvoMenu = ({ currentScreen={currentScreen} /> {latestReportableMessage ? ( - + <> + { + blockOrDeleteControl.open() + }} + /> + + ) : ( )} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 670e677db2..b00b138ad7 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -15,11 +15,12 @@ import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' -import {ReportDialog} from '#/components/dms/ReportDialog' +import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' import {EmojiReactionPicker} from './EmojiReactionPicker' @@ -37,6 +38,7 @@ export let MessageContextMenu = ({ const convo = useConvoActive() const deleteControl = usePromptControl() const reportControl = usePromptControl() + const blockOrDeleteControl = usePromptControl() const langPrefs = useLanguagePrefs() const translate = useTranslate() @@ -171,9 +173,24 @@ export let MessageContextMenu = ({ { + blockOrDeleteControl.open() + }} + /> + { - const {_} = useLingui() - return ( - - - - - - - - ) -} -ReportDialog = memo(ReportDialog) -export {ReportDialog} - -function DialogInner({ - params, - currentScreen, -}: { - params: ReportDialogParams - currentScreen: 'list' | 'conversation' -}) { - const {data: profile, isError} = useProfileQuery({ - did: params.message.sender.did, - }) - const [reportOption, setReportOption] = useState(null) - const [done, setDone] = useState(false) - const control = Dialog.useDialogContext() - - return done ? ( - profile ? ( - - ) : ( - - - - ) - ) : reportOption ? ( - setReportOption(null)} - onComplete={() => { - if (isError) { - control.close() - } else { - setDone(true) - } - }} - /> - ) : ( - - ) -} - -function ReasonStep({ - setReportOption, -}: { - setReportOption: (reportOption: ReportOption) => void - params: ReportDialogParams -}) { - const control = Dialog.useDialogContext() - - return ( - - ) -} - -function SubmitStep({ - params, - reportOption, - goBack, - onComplete, -}: { - params: ReportDialogParams - reportOption: ReportOption - goBack: () => void - onComplete: () => void -}) { - const {_} = useLingui() - const {gtMobile} = useBreakpoints() - const t = useTheme() - const [details, setDetails] = useState('') - const agent = useAgent() - - const { - mutate: submit, - error, - isPending: submitting, - } = useMutation({ - mutationFn: async () => { - if (params.type === 'convoMessage') { - const {convoId, message} = params - const subject: $Typed = { - $type: 'chat.bsky.convo.defs#messageRef', - messageId: message.id, - convoId, - did: message.sender.did, - } - - const report = { - reasonType: reportOption.reason, - subject, - reason: details, - } satisfies ComAtprotoModerationCreateReport.InputSchema - - await agent.createModerationReport(report, { - encoding: 'application/json', - headers: BLUESKY_MOD_SERVICE_HEADERS, - }) - } - }, - onSuccess: onComplete, - }) - - const copy = useMemo(() => { - return { - convoMessage: { - title: _(msg`Report this message`), - }, - }[params.type] - }, [_, params]) - - return ( - - - - - {copy.title} - - - Your report will be sent to the Bluesky Moderation Service - - - - - {params.type === 'convoMessage' && ( - - )} - - - - Reason: - {' '} - {reportOption.title} - - - - - - - Optionally provide additional information below: - - - - - - - - - - - - {error && ( - - - There was an issue sending your report. Please check your internet - connection. - - - )} - - - - - ) -} - -function DoneStep({ - convoId, - currentScreen, - profile, -}: { - convoId: string - currentScreen: 'list' | 'conversation' - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const {_} = useLingui() - const navigation = useNavigation() - const control = Dialog.useDialogContext() - const {gtMobile} = useBreakpoints() - const t = useTheme() - const [actions, setActions] = useState(['block', 'leave']) - const shadow = useProfileShadow(profile) - const [queueBlock] = useProfileBlockMutationQueue(shadow) - - const {mutate: leaveConvo} = useLeaveConvo(convoId, { - onMutate: () => { - if (currentScreen === 'conversation') { - navigation.dispatch( - StackActions.replace('Messages', isNative ? {animation: 'pop'} : {}), - ) - } - }, - onError: () => { - Toast.show(_(msg`Could not leave chat`), 'xmark') - }, - }) - - let btnText = _(msg`Done`) - let toastMsg: string | undefined - if (actions.includes('leave') && actions.includes('block')) { - btnText = _(msg`Block and Delete`) - toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) - } else if (actions.includes('leave')) { - btnText = _(msg`Delete Conversation`) - toastMsg = _(msg({message: 'Conversation deleted', context: 'toast'})) - } else if (actions.includes('block')) { - btnText = _(msg`Block User`) - toastMsg = _(msg({message: 'User blocked', context: 'toast'})) - } - - const onPressPrimaryAction = () => { - control.close(() => { - if (actions.includes('block')) { - queueBlock() - } - if (actions.includes('leave')) { - leaveConvo() - } - if (toastMsg) { - Toast.show(toastMsg, 'check') - } - }) - } - - return ( - - - - Report submitted - - - Our moderation team has received your report. - - - - - - - - Block user - - - - - - Delete conversation - - - - - - - - - - - ) -} - -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 ( - - - - - - - ) -} diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index 4c12b2c2bc..1c35bd0494 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -85,7 +85,7 @@ export function useSubmitReportMutation() { } break } - case 'chatMessage': { + case 'convoMessage': { report = { reasonType, reason: state.details, diff --git a/src/components/moderation/ReportDialog/copy.ts b/src/components/moderation/ReportDialog/copy.ts index 11536c00c2..1027673b12 100644 --- a/src/components/moderation/ReportDialog/copy.ts +++ b/src/components/moderation/ReportDialog/copy.ts @@ -38,10 +38,20 @@ export function useCopyForSubject(subject: ParsedReportSubject) { 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?`), + case 'convoMessage': { + switch (subject.view) { + case 'convo': { + return { + title: _(msg`Report this conversation`), + subtitle: _(msg`Why should this conversation be reviewed?`), + } + } + case 'message': { + return { + title: _(msg`Report this message`), + subtitle: _(msg`Why should this message be reviewed?`), + } + } } } } diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index 06920619f5..a264d12bc2 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -113,6 +113,13 @@ function Inner(props: ReportDialogProps) { const [isPending, setPending] = React.useState(false) const [isSuccess, setSuccess] = React.useState(false) + // some reasons ONLY go to Bluesky + const isBskyOnlyReason = state?.selectedOption?.reason + ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) + : false + // some subjects (chats) only go to Bluesky + const isBskyOnlySubject = props.subject.type === 'convoMessage' + /** * Labelers that support this `subject` and its NSID collection */ @@ -124,7 +131,7 @@ function Inner(props: ReportDialogProps) { if (subjectTypes === undefined) return true if (props.subject.type === 'account') { return subjectTypes.includes('account') - } else if (props.subject.type === 'chatMessage') { + } else if (props.subject.type === 'convoMessage') { return subjectTypes.includes('chat') } else { return subjectTypes.includes('record') @@ -134,13 +141,12 @@ function Inner(props: ReportDialogProps) { 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 + if (props.subject.type === 'convoMessage') return true return collections.includes(props.subject.nsid) }) .filter(l => { if (!state.selectedOption) return false - // some reasons ONLY go to Bluesky - if (BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)) { + if (isBskyOnlyReason || isBskyOnlySubject) { return l.creator.did === BSKY_LABELER_DID } const supportedReasonTypes: string[] | undefined = l.reasonTypes @@ -154,10 +160,25 @@ function Inner(props: ReportDialogProps) { ) ) }) - }, [props, allLabelers, state.selectedOption]) + }, [ + props, + allLabelers, + state.selectedOption, + isBskyOnlyReason, + isBskyOnlySubject, + ]) const hasSupportedLabelers = !!supportedLabelers.length const hasSingleSupportedLabeler = supportedLabelers.length === 1 + /** + * We skip the select labeler step if there's only one possible labeler, and + * that labeler is Bluesky (which is the case for chat reports and certain + * reason types). We'll use this below to adjust the indexing and skip the + * step in the UI. + */ + const isAlwaysBskyLabeler = + hasSingleSupportedLabeler && (isBskyOnlyReason || isBskyOnlySubject) + const onSubmit = React.useCallback(async () => { dispatch({type: 'clearError'}) @@ -185,7 +206,9 @@ function Inner(props: ReportDialogProps) { ) // give time for user feedback setTimeout(() => { - props.control.close() + props.control.close(() => { + props.onAfterSubmit?.() + }) }, 1e3) } catch (e: any) { logger.metric('reportDialog:failure', {}, {statsig: false}) @@ -369,88 +392,104 @@ function Inner(props: ReportDialogProps) { ) : null} - - !state.selectedLabeler} + callback={() => { + dispatch({ + type: 'selectLabeler', + labeler: supportedLabelers[0], + }) + }} /> - {state.activeStepIndex1 >= 3 && ( - <> - {state.selectedLabeler ? ( - <> - {hasSingleSupportedLabeler ? ( - - ) : ( - - - + ) : ( + + + {state.activeStepIndex1 >= 3 && ( + <> + {state.selectedLabeler ? ( + <> + {hasSingleSupportedLabeler ? ( + + ) : ( + + + + + - - - )} - - ) : ( - <> - {hasSupportedLabelers ? ( - - {hasSingleSupportedLabeler ? ( - <> - - !state.selectedLabeler} - callback={() => { - dispatch({ - type: 'selectLabeler', - labeler: supportedLabelers[0], - }) - }} - /> - - ) : ( - <> - {supportedLabelers.map(l => ( - { - dispatch({type: 'selectLabeler', labeler: l}) + )} + + ) : ( + <> + {hasSupportedLabelers ? ( + + {hasSingleSupportedLabeler ? ( + <> + + !state.selectedLabeler} + callback={() => { + dispatch({ + type: 'selectLabeler', + labeler: supportedLabelers[0], + }) }} /> - ))} - - )} - - ) : ( - // should never happen in our app - - - Unfortunately, none of your subscribed labelers supports - this report type. - - - )} - - )} - - )} - + + ) : ( + <> + {supportedLabelers.map(l => ( + { + dispatch({type: 'selectLabeler', labeler: l}) + }} + /> + ))} + + )} + + ) : ( + // should never happen in our app + + + Unfortunately, none of your subscribed labelers + supports this report type. + + + )} + + )} + + )} + + )} {state.activeStepIndex1 === 4 && ( <> diff --git a/src/components/moderation/ReportDialog/types.ts b/src/components/moderation/ReportDialog/types.ts index e84369905c..6df3a82ff6 100644 --- a/src/components/moderation/ReportDialog/types.ts +++ b/src/components/moderation/ReportDialog/types.ts @@ -8,6 +8,12 @@ import { import type * as Dialog from '#/components/Dialog' +export type ReportSubjectConvo = { + view: 'convo' | 'message' + convoId: string + message: ChatBskyConvoDefs.MessageView +} + export type ReportSubject = | $Typed | $Typed @@ -16,7 +22,7 @@ export type ReportSubject = | $Typed | $Typed | $Typed - | {convoId: string; message: ChatBskyConvoDefs.MessageView} + | ReportSubjectConvo export type ParsedReportSubject = | { @@ -55,13 +61,15 @@ export type ParsedReportSubject = did: string nsid: string } - | { - type: 'chatMessage' - convoId: string - message: ChatBskyConvoDefs.MessageView - } + | ({ + type: 'convoMessage' + } & ReportSubjectConvo) export type ReportDialogProps = { control: Dialog.DialogOuterProps['control'] subject: ParsedReportSubject + /** + * Called if the report was successfully submitted. + */ + onAfterSubmit?: () => void } diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts index baab1934fd..0abfe1d9fd 100644 --- a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -18,7 +18,7 @@ export function parseReportSubject( if ('convoId' in subject) { return { - type: 'chatMessage', + type: 'convoMessage', ...subject, } } diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index 4437a9a43f..4b53b40420 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -25,12 +25,13 @@ import { EmailDialogScreenID, useEmailDialogControl, } from '#/components/dialogs/EmailDialog' -import {ReportDialog} from '#/components/dms/ReportDialog' +import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {CircleX_Stroke2_Corner0_Rounded} from '#/components/icons/CircleX' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' +import {ReportDialog} from '#/components/moderation/ReportDialog' export function RejectMenu({ convo, @@ -100,6 +101,7 @@ export function RejectMenu({ }, [queueBlock, leaveConvo, _]) const reportControl = useDialogControl() + const blockOrDeleteControl = useDialogControl() const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage) ? convo.lastMessage @@ -162,15 +164,27 @@ export function RejectMenu({ {lastMessage && ( - + <> + { + blockOrDeleteControl.open() + }} + /> + + )} )