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 {type ReportDialogProps, type ReportSubject} from './types'
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'
@@ -95,7 +99,7 @@ function Inner(props: ReportDialogProps) {
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
const isLoading = useDelayedLoading(500, isLabelerLoading)
const copy = useCopyForSubject(props.subject)
const reportOptions = useReportOptions()
const {reasons, sortedReasons, getTopLevelReason} = useReportOptions()
const [state, dispatch] = React.useReducer(reducer, initialState)
/**
@@ -237,32 +241,32 @@ function Inner(props: ReportDialogProps) {
</Admonition.Outer>
) : (
<>
{state.selectedOption ? (
{state.selectedTopLevelOption ? (
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<View style={[a.flex_1]}>
<OptionCard option={state.selectedOption} />
<TopLevelOptionCard option={state.selectedTopLevelOption} />
</View>
<Button
testID="report:clearOption"
testID="report:clearTopLevelOption"
label={_(msg`Change report reason`)}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
dispatch({type: 'clearTopLevelOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
) : (
<View style={[a.gap_sm]}>
{reportOptions[props.subject.type].map(o => (
<OptionCard
key={o.reason}
{sortedReasons.map(o => (
<TopLevelOptionCard
key={o.key}
option={o}
onSelect={() => {
dispatch({type: 'selectOption', option: o})
dispatch({type: 'selectTopLevelOption', option: o})
}}
/>
))}
@@ -307,6 +311,82 @@ function Inner(props: ReportDialogProps) {
)}
</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>
<StepTitle
index={2}
@@ -569,12 +649,12 @@ function StepTitle({
)
}
function OptionCard({
function TopLevelOptionCard({
option,
onSelect,
}: {
option: ReportOption
onSelect?: (option: ReportOption) => void
option: TopLevelReportOption
onSelect?: (option: TopLevelReportOption) => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -584,7 +664,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 +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() {
const t = useTheme()
return (
@@ -1,8 +1,9 @@
import {type AppBskyLabelerDefs, ComAtprotoModerationDefs} from '@atproto/api'
import {type ReportOption} from './utils/useReportOptions'
import {TopLevelReportOption, type ReportOption} from './utils/useReportOptions'
export type ReportState = {
selectedTopLevelOption?: TopLevelReportOption
selectedOption?: ReportOption
selectedLabeler?: AppBskyLabelerDefs.LabelerViewDetailed
details?: string
@@ -12,6 +13,13 @@ export type ReportState = {
}
export type ReportAction =
| {
type: 'selectTopLevelOption'
option: TopLevelReportOption
}
| {
type: 'clearTopLevelOption'
}
| {
type: 'selectOption'
option: ReportOption
@@ -42,6 +50,7 @@ export type ReportAction =
}
export const initialState: ReportState = {
selectedTopLevelOption: undefined,
selectedOption: undefined,
selectedLabeler: undefined,
details: undefined,
@@ -51,11 +60,29 @@ export const initialState: ReportState = {
export function reducer(state: ReportState, action: ReportAction): ReportState {
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':
return {
...state,
selectedOption: action.option,
activeStepIndex1: 2,
activeStepIndex1: 3,
detailsOpen:
!!state.details ||
action.option.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -65,7 +92,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
...state,
selectedOption: undefined,
selectedLabeler: undefined,
activeStepIndex1: 1,
activeStepIndex1: 2,
detailsOpen:
!!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -74,13 +101,13 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
return {
...state,
selectedLabeler: action.labeler,
activeStepIndex1: 3,
activeStepIndex1: 4,
}
case 'clearLabeler':
return {
...state,
selectedLabeler: undefined,
activeStepIndex1: 2,
activeStepIndex1: 3,
detailsOpen:
!!state.details ||
state.selectedOption?.reason === ComAtprotoModerationDefs.REASONOTHER,
@@ -1,121 +1,307 @@
import {useMemo} from 'react'
import {ComAtprotoModerationDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export interface ReportOption {
reason: string
export type TopLevelReason =
| 'childSafety'
| 'violencePhysicalHarm'
| 'sexualAdultContent'
| 'harassmentHate'
| 'misleading'
| 'ruleBreaking'
| 'civicIntegrity'
| 'other'
export interface TopLevelReportOption {
key: TopLevelReason
title: string
description: string
options: ReportOption[]
sort: number
isOther?: boolean
}
interface ReportOptions {
account: ReportOption[]
post: ReportOption[]
list: ReportOption[]
starterPack: ReportOption[]
feed: ReportOption[]
chatMessage: ReportOption[]
export interface TopLevelReportOptions {
childSafety: TopLevelReportOption
violencePhysicalHarm: TopLevelReportOption
sexualAdultContent: TopLevelReportOption
harassmentHate: TopLevelReportOption
misleading: TopLevelReportOption
ruleBreaking: TopLevelReportOption
civicIntegrity: TopLevelReportOption
other: TopLevelReportOption
}
export function useReportOptions(): ReportOptions {
export interface ReportOption {
title: string
reason: string
}
export function useReportOptions() {
const {_} = useLingui()
return useMemo(() => {
const other = {
reason: ComAtprotoModerationDefs.REASONOTHER,
const reasons = {
childSafety: {
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`),
description: _(msg`An issue not included in these options`),
}
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: [
sort: 8,
options: [
{
reason: ComAtprotoModerationDefs.REASONMISLEADING,
title: _(msg`Misleading Account`),
description: _(
msg`Impersonation or false claims about identity or affiliation`,
),
title: _(msg`Other`),
reason: '#reasonOther',
},
{
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,
],
}
}, [_])
isOther: true,
},
}
const sortedReasons = Object.values(reasons).sort((a, b) => a.sort - b.sort)
const getTopLevelReason = (reasonName: TopLevelReason) => {
return reasons[reasonName]
}
return {reasons, sortedReasons, getTopLevelReason}
}