From d6ca9e6b4131c5e1b3bc030b1edf5005cbe61c13 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 6 Nov 2025 13:40:13 -0800 Subject: [PATCH] [Reporting] Add new reporting categories and reasons (#9079) * start adding new report reasons * cleanup * use updated types * cleanup open/closed logic * other fix * state fix * rename type * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/index.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * Update src/components/moderation/ReportDialog/utils/useReportOptions.ts Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * add mapping * Replace consts and maps using exported values * Map to deprecated reason if necessary, send some reports only to Bluesky * Use only new report reason types * Cleanup * Bump package, integrate new reasons * Adjust sorting, return type, integrate feedback * Just remove sort * Finish 'other' handling * Update descriptions * Just invert filter logic, no functional change but feels more correct * Clarify handling * fix deep imports * fix type error * [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 --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey 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 | 33 +- .../moderation/ReportDialog/const.ts | 112 +++++ .../moderation/ReportDialog/copy.ts | 18 +- .../moderation/ReportDialog/index.tsx | 340 ++++++++++---- .../moderation/ReportDialog/state.ts | 57 ++- .../moderation/ReportDialog/types.ts | 20 +- .../ReportDialog/utils/parseReportSubject.ts | 2 +- .../ReportDialog/utils/useReportOptions.ts | 341 ++++++++++---- .../Messages/components/RequestButtons.tsx | 34 +- .../components/PreferenceControls.tsx | 9 +- 20 files changed, 985 insertions(+), 1369 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 2da6e48295..1c35bd0494 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -9,6 +9,7 @@ import {useMutation} from '@tanstack/react-query' import {logger} from '#/logger' import {useAgent} from '#/state/session' +import {NEW_TO_OLD_REASONS_MAP} from './const' import {type ReportState} from './state' import {type ParsedReportSubject} from './types' @@ -31,6 +32,26 @@ export function useSubmitReportMutation() { throw new Error(_(msg`Please select a moderation service`)) } + const labeler = state.selectedLabeler + const labelerSupportedReasonTypes = labeler.reasonTypes || [] + + let reasonType = state.selectedOption.reason + const backwardsCompatibleReasonType = NEW_TO_OLD_REASONS_MAP[reasonType] + const supportsNewReasonType = + labelerSupportedReasonTypes.includes(reasonType) + const supportsOldReasonType = labelerSupportedReasonTypes.includes( + backwardsCompatibleReasonType, + ) + + /* + * Only fall back for backwards compatibility if the labeler + * does not support the new reason type. If the labeler does not declare + * supported reason types, send the new version. + */ + if (supportsOldReasonType && !supportsNewReasonType) { + reasonType = backwardsCompatibleReasonType + } + let report: | ComAtprotoModerationCreateReport.InputSchema | (Omit & { @@ -40,7 +61,7 @@ export function useSubmitReportMutation() { switch (subject.type) { case 'account': { report = { - reasonType: state.selectedOption.reason, + reasonType, reason: state.details, subject: { $type: 'com.atproto.admin.defs#repoRef', @@ -54,7 +75,7 @@ export function useSubmitReportMutation() { case 'feed': case 'starterPack': { report = { - reasonType: state.selectedOption.reason, + reasonType, reason: state.details, subject: { $type: 'com.atproto.repo.strongRef', @@ -64,9 +85,9 @@ export function useSubmitReportMutation() { } break } - case 'chatMessage': { + case 'convoMessage': { report = { - reasonType: state.selectedOption.reason, + reasonType, reason: state.details, subject: { $type: 'chat.bsky.convo.defs#messageRef', @@ -82,7 +103,7 @@ export function useSubmitReportMutation() { if (__DEV__) { logger.info('Submitting report', { labeler: { - handle: state.selectedLabeler.creator.handle, + handle: labeler.creator.handle, }, report, }) @@ -90,7 +111,7 @@ export function useSubmitReportMutation() { await agent.createModerationReport(report, { encoding: 'application/json', headers: { - 'atproto-proxy': `${state.selectedLabeler.creator.did}#atproto_labeler`, + 'atproto-proxy': `${labeler.creator.did}#atproto_labeler`, }, }) } diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index 0f3fdbfacf..14ff397955 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -1,2 +1,114 @@ +import { + ComAtprotoModerationDefs as RootReportDefs, + ToolsOzoneReportDefs as OzoneReportDefs, +} from '@atproto/api' + export const DMCA_LINK = 'https://bsky.social/about/support/copyright' export const SUPPORT_PAGE = 'https://bsky.social/about/support' + +export const NEW_TO_OLD_REASON_MAPPING: Record = {} + +/** + * Mapping of new (Ozone namespace) reason types to old reason types. + * + * Matches the mapping defined in the Ozone codebase: + * @see https://github.com/bluesky-social/atproto/blob/4c15fb47cec26060bff2e710e95869a90c9d7fdd/packages/ozone/src/mod-service/profile.ts#L16-L64 + */ +export const NEW_TO_OLD_REASONS_MAP: Record< + OzoneReportDefs.ReasonType, + RootReportDefs.ReasonType +> = { + [OzoneReportDefs.REASONAPPEAL]: RootReportDefs.REASONAPPEAL, + [OzoneReportDefs.REASONOTHER]: RootReportDefs.REASONOTHER, + + [OzoneReportDefs.REASONVIOLENCEANIMAL]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCETHREATS]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCEGRAPHICCONTENT]: + RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCEGLORIFICATION]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT]: + RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCETRAFFICKING]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONVIOLENCEOTHER]: RootReportDefs.REASONVIOLATION, + + [OzoneReportDefs.REASONSEXUALABUSECONTENT]: RootReportDefs.REASONSEXUAL, + [OzoneReportDefs.REASONSEXUALNCII]: RootReportDefs.REASONSEXUAL, + [OzoneReportDefs.REASONSEXUALDEEPFAKE]: RootReportDefs.REASONSEXUAL, + [OzoneReportDefs.REASONSEXUALANIMAL]: RootReportDefs.REASONSEXUAL, + [OzoneReportDefs.REASONSEXUALUNLABELED]: RootReportDefs.REASONSEXUAL, + [OzoneReportDefs.REASONSEXUALOTHER]: RootReportDefs.REASONSEXUAL, + + [OzoneReportDefs.REASONCHILDSAFETYCSAM]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONCHILDSAFETYGROOM]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONCHILDSAFETYPRIVACY]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONCHILDSAFETYHARASSMENT]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONCHILDSAFETYOTHER]: RootReportDefs.REASONVIOLATION, + + [OzoneReportDefs.REASONHARASSMENTTROLL]: RootReportDefs.REASONRUDE, + [OzoneReportDefs.REASONHARASSMENTTARGETED]: RootReportDefs.REASONRUDE, + [OzoneReportDefs.REASONHARASSMENTHATESPEECH]: RootReportDefs.REASONRUDE, + [OzoneReportDefs.REASONHARASSMENTDOXXING]: RootReportDefs.REASONRUDE, + [OzoneReportDefs.REASONHARASSMENTOTHER]: RootReportDefs.REASONRUDE, + + [OzoneReportDefs.REASONMISLEADINGBOT]: RootReportDefs.REASONMISLEADING, + [OzoneReportDefs.REASONMISLEADINGIMPERSONATION]: + RootReportDefs.REASONMISLEADING, + [OzoneReportDefs.REASONMISLEADINGSPAM]: RootReportDefs.REASONSPAM, + [OzoneReportDefs.REASONMISLEADINGSCAM]: RootReportDefs.REASONMISLEADING, + [OzoneReportDefs.REASONMISLEADINGELECTIONS]: RootReportDefs.REASONMISLEADING, + [OzoneReportDefs.REASONMISLEADINGOTHER]: RootReportDefs.REASONMISLEADING, + + [OzoneReportDefs.REASONRULESITESECURITY]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONRULEPROHIBITEDSALES]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONRULEBANEVASION]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONRULEOTHER]: RootReportDefs.REASONVIOLATION, + + [OzoneReportDefs.REASONSELFHARMCONTENT]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONSELFHARMED]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONSELFHARMSTUNTS]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONSELFHARMSUBSTANCES]: RootReportDefs.REASONVIOLATION, + [OzoneReportDefs.REASONSELFHARMOTHER]: RootReportDefs.REASONVIOLATION, +} + +/** + * Mapping of old reason types to new (Ozone namespace) reason types. + * @see https://github.com/bluesky-social/proposals/tree/main/0009-mod-report-granularity#backwards-compatibility + */ +export const OLD_TO_NEW_REASONS_MAP: Record< + Exclude, + OzoneReportDefs.ReasonType +> = { + [RootReportDefs.REASONSPAM]: [OzoneReportDefs.REASONMISLEADINGSPAM], + [RootReportDefs.REASONVIOLATION]: [OzoneReportDefs.REASONRULEOTHER], + [RootReportDefs.REASONMISLEADING]: [OzoneReportDefs.REASONMISLEADINGOTHER], + [RootReportDefs.REASONSEXUAL]: [OzoneReportDefs.REASONSEXUALUNLABELED], + [RootReportDefs.REASONRUDE]: [OzoneReportDefs.REASONHARASSMENTOTHER], + [RootReportDefs.REASONOTHER]: [OzoneReportDefs.REASONOTHER], + [RootReportDefs.REASONAPPEAL]: [OzoneReportDefs.REASONAPPEAL], +} + +/** + * Set of report reasons that should optionally include additional details from + * the reporter. + */ +export const OTHER_REPORT_REASONS: Set = new Set([ + OzoneReportDefs.REASONVIOLENCEOTHER, + OzoneReportDefs.REASONSEXUALOTHER, + OzoneReportDefs.REASONCHILDSAFETYOTHER, + OzoneReportDefs.REASONHARASSMENTOTHER, + OzoneReportDefs.REASONMISLEADINGOTHER, + OzoneReportDefs.REASONRULEOTHER, + OzoneReportDefs.REASONSELFHARMOTHER, + OzoneReportDefs.REASONOTHER, +]) + +/** + * Set of report reasons that should only be sent to Bluesky's moderation service. + */ +export const BSKY_LABELER_ONLY_REPORT_REASONS: Set = + new Set([ + OzoneReportDefs.REASONCHILDSAFETYCSAM, + OzoneReportDefs.REASONCHILDSAFETYGROOM, + OzoneReportDefs.REASONCHILDSAFETYOTHER, + OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, + ]) 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 ebfbe0dcd3..a264d12bc2 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import {Pressable, View} from 'react-native' import {type ScrollView} from 'react-native-gesture-handler' -import {type AppBskyLabelerDefs} from '@atproto/api' +import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -30,12 +30,20 @@ import {createStaticClick, InlineLinkText, Link} from '#/components/Link' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {useSubmitReportMutation} from './action' -import {SUPPORT_PAGE} from './const' +import { + BSKY_LABELER_ONLY_REPORT_REASONS, + NEW_TO_OLD_REASONS_MAP, + SUPPORT_PAGE, +} from './const' import {useCopyForSubject} from './copy' import {initialState, reducer} from './state' import {type ReportDialogProps, type ReportSubject} from './types' import {parseReportSubject} from './utils/parseReportSubject' -import {type ReportOption, useReportOptions} from './utils/useReportOptions' +import { + type ReportCategoryConfig, + type ReportOption, + useReportOptions, +} from './utils/useReportOptions' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' @@ -95,7 +103,7 @@ function Inner(props: ReportDialogProps) { } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) const isLoading = useDelayedLoading(500, isLabelerLoading) const copy = useCopyForSubject(props.subject) - const reportOptions = useReportOptions() + const {categories, getCategory} = useReportOptions() const [state, dispatch] = React.useReducer(reducer, initialState) /** @@ -105,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 */ @@ -116,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') @@ -126,19 +141,44 @@ 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 true - const reasonTypes: string[] | undefined = l.reasonTypes - if (reasonTypes === undefined) return true - return reasonTypes.includes(state.selectedOption.reason) + if (!state.selectedOption) return false + if (isBskyOnlyReason || isBskyOnlySubject) { + return l.creator.did === BSKY_LABELER_DID + } + const supportedReasonTypes: string[] | undefined = l.reasonTypes + if (supportedReasonTypes === undefined) return true + return ( + // supports new reason type + supportedReasonTypes.includes(state.selectedOption.reason) || + // supports old reason type (backwards compat) + supportedReasonTypes.includes( + NEW_TO_OLD_REASONS_MAP[state.selectedOption.reason], + ) + ) }) - }, [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'}) @@ -166,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}) @@ -237,32 +279,36 @@ function Inner(props: ReportDialogProps) { ) : ( <> - {state.selectedOption ? ( + {state.selectedCategory ? ( - + ) : ( - {reportOptions[props.subject.type].map(o => ( - ( + { - dispatch({type: 'selectOption', option: o}) + dispatch({ + type: 'selectCategory', + option: o, + otherOption: getCategory('other').options[0], + }) }} /> ))} @@ -310,87 +356,142 @@ function Inner(props: ReportDialogProps) { - {state.activeStepIndex1 >= 2 && ( - <> - {state.selectedLabeler ? ( - <> - {hasSingleSupportedLabeler ? ( - - ) : ( - - - + {state.selectedOption ? ( + + + + + + + ) : state.selectedCategory ? ( + + {getCategory(state.selectedCategory.key).options.map(o => ( + { + dispatch({type: 'selectOption', option: o}) + }} + /> + ))} + + ) : null} + + + {isAlwaysBskyLabeler ? ( + !state.selectedLabeler} + callback={() => { + dispatch({ + type: 'selectLabeler', + labeler: supportedLabelers[0], + }) + }} + /> + ) : ( + + + {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 === 3 && ( + {state.activeStepIndex1 === 4 && ( <> @@ -569,12 +670,12 @@ function StepTitle({ ) } -function OptionCard({ +function CategoryCard({ option, onSelect, }: { - option: ReportOption - onSelect?: (option: ReportOption) => void + option: ReportCategoryConfig + onSelect?: (option: ReportCategoryConfig) => void }) { const t = useTheme() const {_} = useLingui() @@ -584,7 +685,7 @@ function OptionCard({ }, [onSelect, option]) return ( + ) +} + function OptionCardSkeleton() { const t = useTheme() return ( diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts index 18ee77df8d..7b15e174b2 100644 --- a/src/components/moderation/ReportDialog/state.ts +++ b/src/components/moderation/ReportDialog/state.ts @@ -1,8 +1,13 @@ -import {type AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api' +import {type AppBskyLabelerDefs} from '@atproto/api' -import {type ReportOption} from './utils/useReportOptions' +import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const' +import { + type ReportCategoryConfig, + type ReportOption, +} from '#/components/moderation/ReportDialog/utils/useReportOptions' export type ReportState = { + selectedCategory?: ReportCategoryConfig selectedOption?: ReportOption selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed details?: string @@ -12,6 +17,14 @@ export type ReportState = { } export type ReportAction = + | { + type: 'selectCategory' + option: ReportCategoryConfig + otherOption: ReportOption + } + | { + type: 'clearCategory' + } | { type: 'selectOption' option: ReportOption @@ -42,6 +55,7 @@ export type ReportAction = } export const initialState: ReportState = { + selectedCategory: undefined, selectedOption: undefined, selectedLabeler: undefined, details: undefined, @@ -51,39 +65,52 @@ export const initialState: ReportState = { export function reducer(state: ReportState, action: ReportAction): ReportState { switch (action.type) { + case 'selectCategory': + return { + ...state, + selectedCategory: action.option, + activeStepIndex1: action.option.key === 'other' ? 3 : 2, + selectedOption: + action.option.key === 'other' ? action.otherOption : undefined, + } + case 'clearCategory': + return { + ...state, + selectedCategory: undefined, + selectedOption: undefined, + selectedLabeler: undefined, + activeStepIndex1: 1, + detailsOpen: false, + } case 'selectOption': return { ...state, selectedOption: action.option, - activeStepIndex1: 2, - detailsOpen: - !!state.details || - action.option.reason === ComAtprotoModerationDefs.REASONOTHER, + activeStepIndex1: 3, + detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason), } case 'clearOption': return { ...state, selectedOption: undefined, selectedLabeler: undefined, - activeStepIndex1: 1, - detailsOpen: - !!state.details || - state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, + activeStepIndex1: 2, + detailsOpen: false, } case 'selectLabeler': return { ...state, selectedLabeler: action.labeler, - activeStepIndex1: 3, + activeStepIndex1: 4, + detailsOpen: state.selectedOption + ? OTHER_REPORT_REASONS.has(state.selectedOption?.reason) + : false, } case 'clearLabeler': return { ...state, selectedLabeler: undefined, - activeStepIndex1: 2, - detailsOpen: - !!state.details || - state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, + activeStepIndex1: 3, } case 'setDetails': return { 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/components/moderation/ReportDialog/utils/useReportOptions.ts b/src/components/moderation/ReportDialog/utils/useReportOptions.ts index 38888d51a4..75dae73f4b 100644 --- a/src/components/moderation/ReportDialog/utils/useReportOptions.ts +++ b/src/components/moderation/ReportDialog/utils/useReportOptions.ts @@ -1,121 +1,256 @@ import {useMemo} from 'react' -import {ComAtprotoModerationDefs} from '@atproto/api' +import {ToolsOzoneReportDefs as OzoneReportDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -export interface ReportOption { - reason: string +export type ReportCategory = + | 'childSafety' + | 'violencePhysicalHarm' + | 'sexualAdultContent' + | 'harassmentHate' + | 'misleading' + | 'ruleBreaking' + | 'selfHarm' + | 'other' + +export type ReportCategoryConfig = { + key: ReportCategory title: string description: string + options: ReportOption[] } -interface ReportOptions { - account: ReportOption[] - post: ReportOption[] - list: ReportOption[] - starterPack: ReportOption[] - feed: ReportOption[] - chatMessage: ReportOption[] +export type ReportOption = { + title: string + reason: OzoneReportDefs.ReasonType } -export function useReportOptions(): ReportOptions { +export function useReportOptions() { const {_} = useLingui() return useMemo(() => { - const other = { - reason: ComAtprotoModerationDefs.REASONOTHER, - title: _(msg`Other`), - description: _(msg`An issue not included in these options`), + const categories: Record = { + misleading: { + key: 'misleading', + title: _(msg`Misleading`), + description: _(msg`Spam or other inauthentic behavior or deception`), + options: [ + { + title: _(msg`Spam`), + reason: OzoneReportDefs.REASONMISLEADINGSPAM, + }, + { + title: _(msg`Scam`), + reason: OzoneReportDefs.REASONMISLEADINGSCAM, + }, + { + title: _(msg`Fake account or bot`), + reason: OzoneReportDefs.REASONMISLEADINGBOT, + }, + { + title: _(msg`Impersonation`), + reason: OzoneReportDefs.REASONMISLEADINGIMPERSONATION, + }, + { + title: _(msg`False information about elections`), + reason: OzoneReportDefs.REASONMISLEADINGELECTIONS, + }, + { + title: _(msg`Other misleading content`), + reason: OzoneReportDefs.REASONMISLEADINGOTHER, + }, + ], + }, + sexualAdultContent: { + key: 'sexualAdultContent', + title: _(msg`Adult content`), + description: _( + msg`Unlabeled, abusive, or non-consensual adult content`, + ), + options: [ + { + title: _(msg`Unlabeled adult content`), + reason: OzoneReportDefs.REASONSEXUALUNLABELED, + }, + { + title: _(msg`Adult sexual abuse content`), + reason: OzoneReportDefs.REASONSEXUALABUSECONTENT, + }, + { + title: _(msg`Non-consensual intimate imagery`), + reason: OzoneReportDefs.REASONSEXUALNCII, + }, + { + title: _(msg`Deepfake adult content`), + reason: OzoneReportDefs.REASONSEXUALDEEPFAKE, + }, + { + title: _(msg`Animal sexual abuse`), + reason: OzoneReportDefs.REASONSEXUALANIMAL, + }, + { + title: _(msg`Other sexual violence content`), + reason: OzoneReportDefs.REASONSEXUALOTHER, + }, + ], + }, + harassmentHate: { + key: 'harassmentHate', + title: _(msg`Harassment or hate`), + description: _(msg`Abusive or discriminatory behavior`), + options: [ + { + title: _(msg`Trolling`), + reason: OzoneReportDefs.REASONHARASSMENTTROLL, + }, + { + title: _(msg`Targeted harassment`), + reason: OzoneReportDefs.REASONHARASSMENTTARGETED, + }, + { + title: _(msg`Hate speech`), + reason: OzoneReportDefs.REASONHARASSMENTHATESPEECH, + }, + { + title: _(msg`Doxxing`), + reason: OzoneReportDefs.REASONHARASSMENTDOXXING, + }, + { + title: _(msg`Other harassing or hateful content`), + reason: OzoneReportDefs.REASONHARASSMENTOTHER, + }, + ], + }, + violencePhysicalHarm: { + key: 'violencePhysicalHarm', + title: _(msg`Violence`), + description: _(msg`Violent or threatening content`), + options: [ + { + title: _(msg`Animal welfare`), + reason: OzoneReportDefs.REASONVIOLENCEANIMAL, + }, + { + title: _(msg`Threats or incitement`), + reason: OzoneReportDefs.REASONVIOLENCETHREATS, + }, + { + title: _(msg`Graphic violent content`), + reason: OzoneReportDefs.REASONVIOLENCEGRAPHICCONTENT, + }, + { + title: _(msg`Glorification of violence`), + reason: OzoneReportDefs.REASONVIOLENCEGLORIFICATION, + }, + { + title: _(msg`Extremist content`), + reason: OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT, + }, + { + title: _(msg`Human trafficking`), + reason: OzoneReportDefs.REASONVIOLENCETRAFFICKING, + }, + { + title: _(msg`Other violent content`), + reason: OzoneReportDefs.REASONVIOLENCEOTHER, + }, + ], + }, + childSafety: { + key: 'childSafety', + title: _(msg`Child safety`), + description: _(msg`Harming or endangering minors`), + options: [ + { + title: _(msg`Child Sexual Abuse Material (CSAM)`), + reason: OzoneReportDefs.REASONCHILDSAFETYCSAM, + }, + { + title: _(msg`Grooming or predatory behavior`), + reason: OzoneReportDefs.REASONCHILDSAFETYGROOM, + }, + { + title: _(msg`Privacy violation of a minor`), + reason: OzoneReportDefs.REASONCHILDSAFETYPRIVACY, + }, + { + title: _(msg`Minor harassment or bullying`), + reason: OzoneReportDefs.REASONCHILDSAFETYHARASSMENT, + }, + { + title: _(msg`Other child safety issue`), + reason: OzoneReportDefs.REASONCHILDSAFETYOTHER, + }, + ], + }, + selfHarm: { + key: 'selfHarm', + title: _(msg`Self-harm or dangerous behaviors`), + description: _(msg`Harmful or high-risk activities`), + options: [ + { + title: _(msg`Content promoting or depicting self-harm`), + reason: OzoneReportDefs.REASONSELFHARMCONTENT, + }, + { + title: _(msg`Eating disorders`), + reason: OzoneReportDefs.REASONSELFHARMED, + }, + { + title: _(msg`Dangerous challenges or activities`), + reason: OzoneReportDefs.REASONSELFHARMSTUNTS, + }, + { + title: _(msg`Dangerous substances or drug abuse`), + reason: OzoneReportDefs.REASONSELFHARMSUBSTANCES, + }, + { + title: _(msg`Other dangerous content`), + reason: OzoneReportDefs.REASONSELFHARMOTHER, + }, + ], + }, + ruleBreaking: { + key: 'ruleBreaking', + title: _(msg`Breaking site rules`), + description: _(msg`Banned activities or security violations`), + options: [ + { + title: _(msg`Hacking or system attacks`), + reason: OzoneReportDefs.REASONRULESITESECURITY, + }, + { + title: _(msg`Promoting or selling prohibited items or services`), + reason: OzoneReportDefs.REASONRULEPROHIBITEDSALES, + }, + { + title: _(msg`Banned user returning`), + reason: OzoneReportDefs.REASONRULEBANEVASION, + }, + { + title: _(msg`Other network rule-breaking`), + reason: OzoneReportDefs.REASONRULEOTHER, + }, + ], + }, + other: { + key: 'other', + title: _(msg`Other`), + description: _(msg`An issue not included in these options`), + options: [ + { + title: _(msg`Other`), + reason: OzoneReportDefs.REASONOTHER, + }, + ], + }, } - 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, - ], + categories: Object.values(categories) as ReportCategoryConfig[], + getCategory(reasonName: ReportCategory) { + return categories[reasonName] + }, } }, [_]) } 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() + }} + /> + + )} ) diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx index e63f06b4a6..84e5530294 100644 --- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx +++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx @@ -1,7 +1,6 @@ import {useMemo} from 'react' import {View} from 'react-native' import {type AppBskyNotificationDefs} from '@atproto/api' -import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky/notification/defs' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -25,7 +24,9 @@ export function PreferenceControls({ * which groups starterpack joins + verified + unverified notifications into a single toggle. */ syncOthers?: Exclude[] - preference?: AppBskyNotificationDefs.Preference | FilterablePreference + preference?: + | AppBskyNotificationDefs.Preference + | AppBskyNotificationDefs.FilterablePreference allowDisableInApp?: boolean }) { if (!preference) @@ -53,7 +54,9 @@ export function Inner({ }: { name: Exclude syncOthers?: Exclude[] - preference: AppBskyNotificationDefs.Preference | FilterablePreference + preference: + | AppBskyNotificationDefs.Preference + | AppBskyNotificationDefs.FilterablePreference allowDisableInApp: boolean }) { const t = useTheme()