[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 <mozzius@protonmail.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
hailey
2025-11-06 13:40:13 -08:00
committed by GitHub
parent fd14bbfef9
commit d6ca9e6b41
20 changed files with 985 additions and 1369 deletions
@@ -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<ComAtprotoModerationCreateReport.InputSchema, 'subject'> & {
@@ -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`,
},
})
}
@@ -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<string, string> = {}
/**
* 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<RootReportDefs.ReasonType, OzoneReportDefs.ReasonType>,
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<OzoneReportDefs.ReasonType> = 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<OzoneReportDefs.ReasonType> =
new Set([
OzoneReportDefs.REASONCHILDSAFETYCSAM,
OzoneReportDefs.REASONCHILDSAFETYGROOM,
OzoneReportDefs.REASONCHILDSAFETYOTHER,
OzoneReportDefs.REASONVIOLENCEEXTREMISTCONTENT,
])
+14 -4
View File
@@ -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?`),
}
}
}
}
}
+244 -96
View File
@@ -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) {
</Admonition.Outer>
) : (
<>
{state.selectedOption ? (
{state.selectedCategory ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
<CategoryCard option={state.selectedCategory} />
</View>
<Button
testID="report:clearOption"
label={_(msg`Change report reason`)}
testID="report:clearCategory"
label={_(msg`Change report category`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
dispatch({type: 'clearCategory'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : (
<View style={[a.gap_sm]}>
{reportOptions[props.subject.type].map(o => (
<OptionCard
key={o.reason}
{categories.map(o => (
<CategoryCard
key={o.key}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
dispatch({
type: 'selectCategory',
option: o,
otherOption: getCategory('other').options[0],
})
}}
/>
))}
@@ -310,87 +356,142 @@ function Inner(props: ReportDialogProps) {
<StepOuter>
<StepTitle
index={2}
title={_(msg`Select moderation service`)}
title={_(msg`Select a reason`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 >= 2 && (
<>
{state.selectedLabeler ? (
<>
{hasSingleSupportedLabeler ? (
<LabelerCard labeler={state.selectedLabeler} />
) : (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<LabelerCard labeler={state.selectedLabeler} />
{state.selectedOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
</View>
<Button
testID="report:clearReportOption"
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : state.selectedCategory ? (
<View style={[a.gap_sm]}>
{getCategory(state.selectedCategory.key).options.map(o => (
<OptionCard
key={o.reason}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
}}
/>
))}
</View>
) : null}
</StepOuter>
{isAlwaysBskyLabeler ? (
<ActionOnce
check={() => !state.selectedLabeler}
callback={() => {
dispatch({
type: 'selectLabeler',
labeler: supportedLabelers[0],
})
}}
/>
) : (
<StepOuter>
<StepTitle
index={3}
title={_(msg`Select moderation service`)}
activeIndex1={state.activeStepIndex1}
/>
{state.activeStepIndex1 >= 3 && (
<>
{state.selectedLabeler ? (
<>
{hasSingleSupportedLabeler ? (
<LabelerCard labeler={state.selectedLabeler} />
) : (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<LabelerCard labeler={state.selectedLabeler} />
</View>
<Button
label={_(msg`Change moderation service`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearLabeler'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
<Button
label={_(msg`Change moderation service`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearLabeler'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
)}
</>
) : (
<>
{hasSupportedLabelers ? (
<View style={[a.gap_sm]}>
{hasSingleSupportedLabeler ? (
<>
<LabelerCard labeler={supportedLabelers[0]} />
<ActionOnce
check={() => !state.selectedLabeler}
callback={() => {
dispatch({
type: 'selectLabeler',
labeler: supportedLabelers[0],
})
}}
/>
</>
) : (
<>
{supportedLabelers.map(l => (
<LabelerCard
key={l.creator.did}
labeler={l}
onSelect={() => {
dispatch({type: 'selectLabeler', labeler: l})
)}
</>
) : (
<>
{hasSupportedLabelers ? (
<View style={[a.gap_sm]}>
{hasSingleSupportedLabeler ? (
<>
<LabelerCard labeler={supportedLabelers[0]} />
<ActionOnce
check={() => !state.selectedLabeler}
callback={() => {
dispatch({
type: 'selectLabeler',
labeler: supportedLabelers[0],
})
}}
/>
))}
</>
)}
</View>
) : (
// should never happen in our app
<Admonition.Admonition type="warning">
<Trans>
Unfortunately, none of your subscribed labelers supports
this report type.
</Trans>
</Admonition.Admonition>
)}
</>
)}
</>
)}
</StepOuter>
</>
) : (
<>
{supportedLabelers.map(l => (
<LabelerCard
key={l.creator.did}
labeler={l}
onSelect={() => {
dispatch({type: 'selectLabeler', labeler: l})
}}
/>
))}
</>
)}
</View>
) : (
// should never happen in our app
<Admonition.Admonition type="warning">
<Trans>
Unfortunately, none of your subscribed labelers
supports this report type.
</Trans>
</Admonition.Admonition>
)}
</>
)}
</>
)}
</StepOuter>
)}
<StepOuter>
<StepTitle
index={3}
index={isAlwaysBskyLabeler ? 3 : 4}
title={_(msg`Submit report`)}
activeIndex1={state.activeStepIndex1}
activeIndex1={
isAlwaysBskyLabeler
? state.activeStepIndex1 - 1
: state.activeStepIndex1
}
/>
{state.activeStepIndex1 === 3 && (
{state.activeStepIndex1 === 4 && (
<>
<View style={[a.pb_xs, a.gap_xs]}>
<Text style={[a.leading_snug, a.pb_xs]}>
@@ -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 (
<Button
testID={`report:option:${option.reason}`}
testID={`report:option:${option.title}`}
label={_(msg`Create report for ${option.title}`)}
onPress={onPress}
disabled={!onSelect}>
@@ -614,6 +715,53 @@ function OptionCard({
)
}
function OptionCard({
option,
onSelect,
}: {
option: ReportOption
onSelect?: (option: ReportOption) => void
}) {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters(['compact'])
const onPress = React.useCallback(() => {
onSelect?.(option)
}, [onSelect, option])
return (
<Button
testID={`report:option:${option.title}`}
label={_(
msg({
message: `Create report for ${option.title}`,
comment:
'Accessibility label for button to create a moderation report for the selected option',
}),
)}
onPress={onPress}
disabled={!onSelect}>
{({hovered, pressed}) => (
<View
style={[
a.w_full,
gutters,
a.py_sm,
a.rounded_sm,
a.border,
t.atoms.bg_contrast_25,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
{option.title}
</Text>
</View>
)}
</Button>
)
}
function OptionCardSkeleton() {
const t = useTheme()
return (
+42 -15
View File
@@ -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 {
@@ -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<AppBskyActorDefs.ProfileViewBasic>
| $Typed<AppBskyActorDefs.ProfileView>
@@ -16,7 +22,7 @@ export type ReportSubject =
| $Typed<AppBskyFeedDefs.GeneratorView>
| $Typed<AppBskyGraphDefs.StarterPackView>
| $Typed<AppBskyFeedDefs.PostView>
| {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
}
@@ -18,7 +18,7 @@ export function parseReportSubject(
if ('convoId' in subject) {
return {
type: 'chatMessage',
type: 'convoMessage',
...subject,
}
}
@@ -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<ReportCategory, ReportCategoryConfig> = {
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]
},
}
}, [_])
}