From 4e4e1e1190cab226a7f92ad9c1b505b86aa53a07 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 27 May 2026 09:30:33 -0700 Subject: [PATCH] Add ability to report a group chat (#10485) --- eslint-suppressions.json | 5 - .../dms/AfterReportConversationDialog.tsx | 258 ++++++++++++++++++ src/components/dms/AfterReportDialog.tsx | 49 ++-- src/components/dms/ConvoMenu.tsx | 23 +- src/components/dms/MessageContextMenu.tsx | 2 +- .../dms/ReportConversationDialog.tsx | 22 ++ .../dms/ReportConversationPrompt.tsx | 26 -- .../moderation/ReportDialog/action.ts | 16 +- .../moderation/ReportDialog/const.ts | 2 +- .../moderation/ReportDialog/copy.ts | 6 + .../moderation/ReportDialog/index.tsx | 100 ++++--- .../moderation/ReportDialog/types.ts | 11 +- .../ReportDialog/utils/parseReportSubject.ts | 11 +- .../Messages/ConversationSettings/index.tsx | 77 +++--- .../Messages/components/RequestButtons.tsx | 2 +- 15 files changed, 452 insertions(+), 158 deletions(-) create mode 100644 src/components/dms/AfterReportConversationDialog.tsx create mode 100644 src/components/dms/ReportConversationDialog.tsx delete mode 100644 src/components/dms/ReportConversationPrompt.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0bc3b4e372..ce02698ce6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -202,11 +202,6 @@ "count": 2 } }, - "src/components/moderation/ReportDialog/index.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "src/features/liveNow/index.tsx": { "@typescript-eslint/no-explicit-any": { "count": 3 diff --git a/src/components/dms/AfterReportConversationDialog.tsx b/src/components/dms/AfterReportConversationDialog.tsx new file mode 100644 index 0000000000..8e484156f4 --- /dev/null +++ b/src/components/dms/AfterReportConversationDialog.tsx @@ -0,0 +1,258 @@ +import {memo, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyActorDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' +import {StackActions, useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + useProfileBlockMutationQueue, + useProfileQuery, +} from '#/state/queries/profile' +import {atoms as a, 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 * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_NATIVE} from '#/env' + +type ReportDialogParams = { + convoId: string + did: string +} + +/** + * Dialog shown after a report is submitted, allowing the user to block the + * reporter and/or leave the conversation. + */ +export const AfterReportConversationDialog = memo( + function BlockOrLeaveDialogInner({ + control, + params, + currentScreen, + }: { + control: Dialog.DialogControlProps + params: ReportDialogParams + currentScreen: 'list' | 'conversation' + }): React.ReactNode { + const {t: l} = useLingui() + return ( + + + + + + + + ) + }, +) + +function DialogInner({ + params, + currentScreen, +}: { + params: ReportDialogParams + currentScreen: 'list' | 'conversation' +}) { + const t = useTheme() + const {t: l} = useLingui() + const control = Dialog.useDialogContext() + const { + data: profile, + isPending, + isError, + } = useProfileQuery({ + did: params.did, + }) + + return isPending ? ( + + + + ) : 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 {t: l} = 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 handleActionsChange = (newActions: string[]) => { + const hadBlock = actions.includes('block') + const hasBlock = newActions.includes('block') + + // If block was just checked, ensure leave is also checked + if (!hadBlock && hasBlock) { + if (!newActions.includes('leave')) { + setActions([...newActions, 'leave']) + } else { + setActions(newActions) + } + } + // If block was just unchecked, also uncheck leave + else if (hadBlock && !hasBlock) { + setActions(newActions.filter(action => action !== 'leave')) + } + // Otherwise, use the new actions as-is (user can toggle leave independently) + else { + setActions(newActions) + } + } + + const {mutate: leaveConvo} = useLeaveConvo(convoId, { + onMutate: () => { + if (currentScreen === 'conversation') { + navigation.dispatch( + StackActions.replace('Messages', IS_NATIVE ? {animation: 'pop'} : {}), + ) + } + }, + onError: () => { + Toast.show(l`Could not leave chat`, { + type: 'error', + }) + }, + }) + + let btnText = l`Done` + let toastMsg: string | undefined + if (actions.includes('leave') && actions.includes('block')) { + btnText = l({ + message: 'Block and leave', + context: 'button', + comment: 'After-report action for a conversation', + }) + toastMsg = l({message: 'Conversation left', context: 'toast'}) + } else if (actions.includes('leave')) { + btnText = l({ + message: 'Leave conversation', + context: 'button', + comment: 'After-report action for a conversation', + }) + toastMsg = l({message: 'Conversation left', context: 'toast'}) + } else if (actions.includes('block')) { + // Shouldn't be able to reach this, but here for completeness. + btnText = l({ + message: 'Block user', + context: 'button', + comment: 'After-report action for a conversation', + }) + toastMsg = l({message: 'User blocked', context: 'toast'}) + } + + const onPressPrimaryAction = () => { + control.close(() => { + if (actions.includes('block')) { + void queueBlock() + } + if (actions.includes('leave')) { + leaveConvo() + } + if (toastMsg) { + Toast.show(toastMsg, { + type: 'success', + }) + } + }) + } + + return ( + + + + Report submitted + + + Our moderation team has received your report. + + + + + + + + Block user + + + + + + Leave conversation + + + + + + + + + + ) +} diff --git a/src/components/dms/AfterReportDialog.tsx b/src/components/dms/AfterReportDialog.tsx index d851037e45..3df194fc8b 100644 --- a/src/components/dms/AfterReportDialog.tsx +++ b/src/components/dms/AfterReportDialog.tsx @@ -1,9 +1,7 @@ import {memo, useState} from 'react' import {View} from 'react-native' -import {type AppBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {type AppBskyActorDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' import {StackActions, useNavigation} from '@react-navigation/native' import {type NavigationProp} from '#/lib/routes/types' @@ -24,7 +22,7 @@ import {IS_NATIVE} from '#/env' type ReportDialogParams = { convoId: string - message: ChatBskyConvoDefs.MessageView + did: string } /** @@ -40,14 +38,12 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({ params: ReportDialogParams currentScreen: 'list' | 'conversation' }): React.ReactNode { - const {_} = useLingui() + const {t: l} = useLingui() return ( @@ -64,14 +60,14 @@ function DialogInner({ currentScreen: 'list' | 'conversation' }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const control = Dialog.useDialogContext() const { data: profile, isPending, isError, } = useProfileQuery({ - did: params.message.sender.did, + did: params.did, }) return isPending ? ( @@ -90,7 +86,7 @@ function DialogInner({