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({