start adding new report reasons

This commit is contained in:
Hailey
2025-09-24 18:51:40 -07:00
committed by Eric Bailey
parent 7861cd153d
commit 45b9ca8678
3 changed files with 457 additions and 123 deletions
+135 -14
View File
@@ -35,7 +35,11 @@ import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state' import {initialState, reducer} from './state'
import {type ReportDialogProps, type ReportSubject} from './types' import {type ReportDialogProps, type ReportSubject} from './types'
import {parseReportSubject} from './utils/parseReportSubject' import {parseReportSubject} from './utils/parseReportSubject'
import {type ReportOption, useReportOptions} from './utils/useReportOptions' import {
type ReportOption,
TopLevelReportOption,
useReportOptions,
} from './utils/useReportOptions'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog' export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
@@ -95,7 +99,7 @@ function Inner(props: ReportDialogProps) {
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) } = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
const isLoading = useDelayedLoading(500, isLabelerLoading) const isLoading = useDelayedLoading(500, isLabelerLoading)
const copy = useCopyForSubject(props.subject) const copy = useCopyForSubject(props.subject)
const reportOptions = useReportOptions() const {reasons, sortedReasons, getTopLevelReason} = useReportOptions()
const [state, dispatch] = React.useReducer(reducer, initialState) const [state, dispatch] = React.useReducer(reducer, initialState)
/** /**
@@ -237,32 +241,32 @@ function Inner(props: ReportDialogProps) {
</Admonition.Outer> </Admonition.Outer>
) : ( ) : (
<> <>
{state.selectedOption ? ( {state.selectedTopLevelOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}> <View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} /> <TopLevelOptionCard option={state.selectedTopLevelOption} />
</View> </View>
<Button <Button
testID="report:clearOption" testID="report:clearTopLevelOption"
label={_(msg`Change report reason`)} label={_(msg`Change report reason`)}
size="tiny" size="tiny"
variant="solid" variant="solid"
color="secondary" color="secondary"
shape="round" shape="round"
onPress={() => { onPress={() => {
dispatch({type: 'clearOption'}) dispatch({type: 'clearTopLevelOption'})
}}> }}>
<ButtonIcon icon={X} /> <ButtonIcon icon={X} />
</Button> </Button>
</View> </View>
) : ( ) : (
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
{reportOptions[props.subject.type].map(o => ( {sortedReasons.map(o => (
<OptionCard <TopLevelOptionCard
key={o.reason} key={o.key}
option={o} option={o}
onSelect={() => { onSelect={() => {
dispatch({type: 'selectOption', option: o}) dispatch({type: 'selectTopLevelOption', option: o})
}} }}
/> />
))} ))}
@@ -307,6 +311,82 @@ function Inner(props: ReportDialogProps) {
)} )}
</StepOuter> </StepOuter>
<StepOuter>
<StepTitle
index={2}
title={_(msg`Select a sub-category`)}
activeIndex1={state.activeStepIndex1}
/>
{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:clearTopLevelOption"
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearTopLevelOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : state.selectedTopLevelOption ? (
<View style={[a.gap_sm]}>
{getTopLevelReason(state.selectedTopLevelOption.key).options.map(
o => (
<OptionCard
key={o.reason}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
}}
/>
),
)}
{['post', 'account'].includes(props.subject.type) && (
<Link
to={SUPPORT_PAGE}
label={_(
msg`Need to report a copyright violation, legal request, or regulatory compliance issue?`,
)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.w_full,
a.px_md,
a.py_sm,
a.rounded_sm,
a.border,
hovered || pressed
? [t.atoms.border_contrast_high]
: [t.atoms.border_contrast_low],
]}>
<Text style={[a.flex_1, a.italic, a.leading_snug]}>
<Trans>
Need to report a copyright violation, legal request,
or regulatory compliance issue?
</Trans>
</Text>
<SquareArrowTopRight
size="sm"
fill={t.atoms.text.color}
/>
</View>
)}
</Link>
)}
</View>
) : null}
</StepOuter>
<StepOuter> <StepOuter>
<StepTitle <StepTitle
index={2} index={2}
@@ -569,12 +649,12 @@ function StepTitle({
) )
} }
function OptionCard({ function TopLevelOptionCard({
option, option,
onSelect, onSelect,
}: { }: {
option: ReportOption option: TopLevelReportOption
onSelect?: (option: ReportOption) => void onSelect?: (option: TopLevelReportOption) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -584,7 +664,7 @@ function OptionCard({
}, [onSelect, option]) }, [onSelect, option])
return ( return (
<Button <Button
testID={`report:option:${option.reason}`} testID={`report:option:${option.title}`}
label={_(msg`Create report for ${option.title}`)} label={_(msg`Create report for ${option.title}`)}
onPress={onPress} onPress={onPress}
disabled={!onSelect}> disabled={!onSelect}>
@@ -614,6 +694,47 @@ 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`Create report for ${option.title}`)}
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() { function OptionCardSkeleton() {
const t = useTheme() const t = useTheme()
return ( return (
@@ -1,8 +1,9 @@
import {type AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api' import {type AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {type ReportOption} from './utils/useReportOptions' import {TopLevelReportOption, type ReportOption} from './utils/useReportOptions'
export type ReportState = { export type ReportState = {
selectedTopLevelOption?: TopLevelReportOption
selectedOption?: ReportOption selectedOption?: ReportOption
selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed
details?: string details?: string
@@ -12,6 +13,13 @@ export type ReportState = {
} }
export type ReportAction = export type ReportAction =
| {
type: 'selectTopLevelOption'
option: TopLevelReportOption
}
| {
type: 'clearTopLevelOption'
}
| { | {
type: 'selectOption' type: 'selectOption'
option: ReportOption option: ReportOption
@@ -42,6 +50,7 @@ export type ReportAction =
} }
export const initialState: ReportState = { export const initialState: ReportState = {
selectedTopLevelOption: undefined,
selectedOption: undefined, selectedOption: undefined,
selectedLabeler: undefined, selectedLabeler: undefined,
details: undefined, details: undefined,
@@ -51,11 +60,29 @@ export const initialState: ReportState = {
export function reducer(state: ReportState, action: ReportAction): ReportState { export function reducer(state: ReportState, action: ReportAction): ReportState {
switch (action.type) { switch (action.type) {
case 'selectTopLevelOption':
return {
...state,
selectedTopLevelOption: action.option,
activeStepIndex1: 2,
detailsOpen: !!state.details,
}
case 'clearTopLevelOption':
return {
...state,
selectedTopLevelOption: undefined,
selectedOption: undefined,
selectedLabeler: undefined,
activeStepIndex1: 1,
detailsOpen:
!!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
}
case 'selectOption': case 'selectOption':
return { return {
...state, ...state,
selectedOption: action.option, selectedOption: action.option,
activeStepIndex1: 2, activeStepIndex1: 3,
detailsOpen: detailsOpen:
!!state.details || !!state.details ||
action.option.reason === ComAtprotoModerationDefs.REASONOTHER, action.option.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -65,7 +92,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
...state, ...state,
selectedOption: undefined, selectedOption: undefined,
selectedLabeler: undefined, selectedLabeler: undefined,
activeStepIndex1: 1, activeStepIndex1: 2,
detailsOpen: detailsOpen:
!!state.details || !!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -74,13 +101,13 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
return { return {
...state, ...state,
selectedLabeler: action.labeler, selectedLabeler: action.labeler,
activeStepIndex1: 3, activeStepIndex1: 4,
} }
case 'clearLabeler': case 'clearLabeler':
return { return {
...state, ...state,
selectedLabeler: undefined, selectedLabeler: undefined,
activeStepIndex1: 2, activeStepIndex1: 3,
detailsOpen: detailsOpen:
!!state.details || !!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER, state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -1,121 +1,307 @@
import {useMemo} from 'react'
import {ComAtprotoModerationDefs} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
export interface ReportOption { export type TopLevelReason =
reason: string | 'childSafety'
| 'violencePhysicalHarm'
| 'sexualAdultContent'
| 'harassmentHate'
| 'misleading'
| 'ruleBreaking'
| 'civicIntegrity'
| 'other'
export interface TopLevelReportOption {
key: TopLevelReason
title: string title: string
description: string description: string
options: ReportOption[]
sort: number
isOther?: boolean
} }
interface ReportOptions { export interface TopLevelReportOptions {
account: ReportOption[] childSafety: TopLevelReportOption
post: ReportOption[] violencePhysicalHarm: TopLevelReportOption
list: ReportOption[] sexualAdultContent: TopLevelReportOption
starterPack: ReportOption[] harassmentHate: TopLevelReportOption
feed: ReportOption[] misleading: TopLevelReportOption
chatMessage: ReportOption[] ruleBreaking: TopLevelReportOption
civicIntegrity: TopLevelReportOption
other: TopLevelReportOption
} }
export function useReportOptions(): ReportOptions { export interface ReportOption {
title: string
reason: string
}
export function useReportOptions() {
const {_} = useLingui() const {_} = useLingui()
return useMemo(() => { const reasons = {
const other = { childSafety: {
reason: ComAtprotoModerationDefs.REASONOTHER, key: 'childSafety',
title: _(msg`Child Safety`),
description: _(
msg`Content harming or endangering minors' safety and wellbeing`,
),
sort: 1,
options: [
{
title: _(msg`Child Sexual Abuse Material (CSAM)`),
reason: '#reasonChildSafetyCSAM',
},
{
title: _(msg`Grooming or Predatory Behavior`),
reason: '#reasonChildSafetyGroom',
},
{
title: _(msg`Minor Privacy Violation`),
reason: '#reasonChildSafetyMinorPrivacy',
},
{
title: _(msg`Child Endangerment`),
reason: '#reasonChildSafetyEndangerment',
},
{
title: _(msg`Minor Harassment or Bullying`),
reason: '#reasonChildSafetyHarassment',
},
{
title: _(msg`Promotion of Child Exploitation`),
reason: '#reasonChildSafetyPromotion',
},
{
title: _(msg`Other Child Safety`),
reason: '#reasonChildSafetyOther',
},
],
},
violencePhysicalHarm: {
key: 'violencePhysicalHarm',
title: _(msg`Violence of Physical Harm`),
sort: 2,
description: _(msg`Threats, calls for violence, or graphic content`),
options: [
{
title: _(msg`Animal Welfare`),
reason: '#reasonViolenceAnimalWelfare',
},
{
title: _(msg`Threats or Inticement`),
reason: '#reasonViolenceThreats',
},
{
title: _(msg`Graphic Violent Content`),
reason: '#reasonViolenceGraphicContent',
},
{
title: _(msg`Self Harm`),
reason: '#reasonViolenceSelfHarm',
},
{
title: _(msg`Glorification of Violence`),
reason: '#reasonViolenceGlorification',
},
{
title: _(msg`Extermist Content`),
reason: '#reasonViolenceExtermistContent',
},
{
title: _(msg`Human Trafficking`),
reason: '#reasonViolenceTrafficking',
},
{
title: _(msg`Other Violent Content`),
reason: '#reasonViolenceOther',
},
],
},
sexualAdultContent: {
key: 'sexualAdultContent',
title: _(msg`Sexaul and Adult Content`),
description: _(msg`Adult, child or animal sexual abuse`),
sort: 3,
options: [
{
title: _(msg`Adult Sexual Abuse Content`),
reason: '#reasonSexualAbuseContent',
},
{
title: _(msg`Non-Consensual Intimate Imagery`),
reason: '#reasonSexualNCII',
},
{
title: _(msg`SexualSextortion`),
reason: '#reasonSexualSextortion',
},
{
title: _(msg`Deepfake Adult Content`),
reason: '#reasonSexualDeepfake',
},
{
title: _(msg`Animal Sexual Abuse`),
reason: '#reasonSexualAnimal',
},
{
title: _(msg`Unlabelled Adult Content`),
reason: '#reasonSexualUnlabelled',
},
{
title: _(msg`Other Sexual Violence Content`),
reason: '#reasonSexualOther',
},
],
},
harassmentHate: {
key: 'harassmentHate',
title: _(msg`Harassment or Hate`),
description: _(
msg`Targeted attacks, hate speech, or organized harassment`,
),
sort: 4,
options: [
{
title: _(msg`Trolling`),
reason: '#reasonHarassmentTroll',
},
{
title: _(msg`Targeted Harassment`),
reason: '#reasonHarassmentTargeted',
},
{
title: _(msg`Hate Speech`),
reason: '#reasonHarassmentHateSpeech',
},
{
title: _(msg`Doxxing`),
reason: '#reasonHarassmentDoxxing',
},
{
title: _(msg`Other Harassing or Hateful Content`),
reason: '#reasonHarassmentOther',
},
],
},
misleading: {
key: 'misleading',
title: _(msg`Misleading`),
description: _(msg`Spam, scams, false info or impersonation`),
sort: 5,
options: [
{
title: _(msg`Fake Account or Bot`),
reason: '#reasonMisleadingBot',
},
{
title: _(msg`Impersonation`),
reason: '#reasonMisleadingImpersonation',
},
{
title: _(msg`Spam`),
reason: '#reasonMisleadingSpam',
},
{
title: _(msg`Scam`),
reason: '#reasonMisleadingScam',
},
{
title: _(msg`Unlabelled GenAI or Synthetic Content`),
reason: '#reasonMisleadingSyntheticContent',
},
{
title: _(msg`Harmful False Claims`),
reason: '#reasonMisleadingMisinformation',
},
{
title: _(msg`Other Misleading Content`),
reason: '#reasonMisleadingOther',
},
],
},
ruleBreaking: {
key: 'ruleBreaking',
title: _(msg`Breaking Network Rules`),
description: _(msg`Hacking, stolen content or prohibited sales`),
sort: 6,
options: [
{
title: _(msg`Hacking or System Attacks`),
reason: '#reasonRuleSiteSecurity',
},
{
title: _(msg`Stolen Content`),
reason: '#reasonRuleStolenContent',
},
{
title: _(msg`Promoting or Selling Prohibited Items of Services`),
reason: '#reasonRuleProhibitedSales',
},
{
title: _(msg`Banned User Returning`),
reason: '#reasonRuleBanEvasion',
},
{
title: _(msg`Other`),
reason: '#reasonRuleOther',
},
],
},
civicIntegrity: {
key: 'civicIntegrity',
title: _(msg`Civic Integrity`),
description: _(
msg`Electoral interference or political process violations`,
),
sort: 7,
options: [
{
title: _(msg`Electoral Process Violations`),
reason: '#reasonCivicElectoralProcess',
},
{
title: _(msg`Disclosure and Transparency Violations`),
reason: '#reasonCivicDisclosure',
},
{
title: _(msg`Voter Intimidation or Interference`),
reason: '#reasonCivicInterference',
},
{
title: _(msg`Election Misinformation`),
reason: '#reasonCivicMisinformation',
},
{
title: _(msg`Impersation of Electoral Officials/Entities`),
reason: '#reasonCivicImpersonation',
},
{
title: _(msg`Other`),
reason: '#reasonCivicOther',
},
],
},
other: {
key: 'other',
title: _(msg`Other`), title: _(msg`Other`),
description: _(msg`An issue not included in these options`), description: _(msg`An issue not included in these options`),
} sort: 8,
const common = [ options: [
{
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`Other`),
title: _(msg`Misleading Account`), reason: '#reasonOther',
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: [ isOther: true,
{ },
reason: ComAtprotoModerationDefs.REASONMISLEADING, }
title: _(msg`Misleading Post`),
description: _(msg`Impersonation, misinformation, or false claims`), const sortedReasons = Object.values(reasons).sort((a, b) => a.sort - b.sort)
},
{ const getTopLevelReason = (reasonName: TopLevelReason) => {
reason: ComAtprotoModerationDefs.REASONSPAM, return reasons[reasonName]
title: _(msg`Spam`), }
description: _(msg`Excessive mentions or replies`),
}, return {reasons, sortedReasons, getTopLevelReason}
{
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,
],
}
}, [_])
} }