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 (