Add TIDA form URL for adult content report (#11150)

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Foysal Ahamed
2026-07-29 17:38:27 +02:00
committed by GitHub
parent b57827f23f
commit c6a40088dd
4 changed files with 322 additions and 21 deletions
@@ -7,6 +7,7 @@ import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/typ
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
export const NCII_FORM = 'https://forms.bsky.app/f/ncii'
export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {}
+149 -17
View File
@@ -40,11 +40,18 @@ import {useSubmitReportMutation} from './action'
import {
BSKY_LABELER_ONLY_REPORT_REASONS,
BSKY_LABELER_ONLY_SUBJECT_TYPES,
NCII_FORM,
NEW_TO_OLD_REASONS_MAP,
SUPPORT_PAGE,
} from './const'
import {useCopyForSubject} from './copy'
import {initialState, reducer} from './state'
import {
getNciiQualificationOutcome,
initialState,
type NciiQualification as NciiQualificationState,
reducer,
type ReportAction,
} from './state'
import {type ReportDialogProps, type ReportSubject} from './types'
import {parseReportSubject} from './utils/parseReportSubject'
import {
@@ -381,23 +388,28 @@ function Inner(props: ReportDialogProps) {
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 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={l`Change report reason`}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
<Button
testID="report:clearReportOption"
label={l`Change report reason`}
size="tiny"
variant="solid"
color="secondary"
shape="round"
onPress={() => {
dispatch({type: 'clearOption'})
}}>
<ButtonIcon icon={X} />
</Button>
</View>
{state.ncii && (
<NciiQualification ncii={state.ncii} dispatch={dispatch} />
)}
</>
) : state.selectedCategory ? (
<View style={[a.gap_sm]}>
{getCategory(state.selectedCategory.key).options.map(o => (
@@ -780,6 +792,126 @@ function OptionCard({
)
}
/**
* Qualifying question shown when the NCII reason is selected. The depicted
* person (or their authorized representative) is directed to the external
* NCII report form; everyone else continues with the normal in-app
* submission.
*/
function NciiQualification({
ncii,
dispatch,
}: {
ncii: NciiQualificationState
dispatch: React.Dispatch<ReportAction>
}) {
const t = useTheme()
const {t: l} = useLingui()
const outcome = getNciiQualificationOutcome(ncii)
return (
<View style={[a.gap_md]}>
<YesNoQuestion
testID="report:ncii:isDepicted"
question={l`Are you the person depicted, or an authorized representative acting on behalf of the person depicted?`}
value={ncii.isDepicted}
onAnswer={answer => {
dispatch({
type: 'answerNciiQuestion',
question: 'isDepicted',
answer,
})
}}
/>
{outcome === 'externalForm' && (
<Link
to={NCII_FORM}
label={l({
message:
'Submit your report through the Report non-consensual intimate imagery (NCII) form',
context: 'english-only-resource',
})}>
{({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 context="english-only-resource">
Please submit your report through the Report non-consensual
intimate imagery (NCII) form.
</Trans>
</Text>
<SquareArrowTopRight size="sm" fill={t.atoms.text.color} />
</View>
)}
</Link>
)}
</View>
)
}
function YesNoQuestion({
question,
value,
onAnswer,
testID,
}: {
question: string
value?: boolean
onAnswer: (answer: boolean) => void
testID?: string
}) {
const {t: l} = useLingui()
return (
<View style={[a.gap_sm]}>
<Text style={[a.text_sm, a.leading_snug]}>{question}</Text>
<View style={[a.flex_row, a.gap_sm]}>
<View style={[a.flex_1]}>
<Button
testID={testID ? `${testID}:yes` : undefined}
label={l({
message: 'Yes',
context: 'Answer to a yes/no question',
})}
accessibilityHint={question}
size="small"
color={value === true ? 'primary' : 'secondary'}
onPress={() => onAnswer(true)}>
<ButtonText>
<Trans context="Answer to a yes/no question">Yes</Trans>
</ButtonText>
</Button>
</View>
<View style={[a.flex_1]}>
<Button
testID={testID ? `${testID}:no` : undefined}
label={l({
message: 'No',
context: 'Answer to a yes/no question',
})}
accessibilityHint={question}
size="small"
color={value === false ? 'primary' : 'secondary'}
onPress={() => onAnswer(false)}>
<ButtonText>
<Trans context="Answer to a yes/no question">No</Trans>
</ButtonText>
</Button>
</View>
</View>
</View>
)
}
function OptionCardSkeleton() {
const t = useTheme()
return (
@@ -0,0 +1,110 @@
import {
type AppBskyLabelerDefs,
ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api'
import {
getNciiQualificationOutcome,
initialState,
reducer,
type ReportState,
} from './state'
const nciiOption = {
title: 'Non-consensual intimate imagery',
reason: OzoneReportDefs.REASONSEXUALNCII,
}
const otherOption = {
title: 'Unlabeled adult content',
reason: OzoneReportDefs.REASONSEXUALUNLABELED,
}
function selectNciiOption(state: ReportState = initialState) {
return reducer(state, {type: 'selectOption', option: nciiOption})
}
describe('getNciiQualificationOutcome', () => {
it('returns undefined when not an NCII report', () => {
expect(getNciiQualificationOutcome(undefined)).toBeUndefined()
})
it('is pending until the question is answered', () => {
expect(getNciiQualificationOutcome({})).toBe('pending')
})
it('directs the depicted person to the external form', () => {
expect(getNciiQualificationOutcome({isDepicted: true})).toBe('externalForm')
})
it('directs everyone else to in-app submission', () => {
expect(getNciiQualificationOutcome({isDepicted: false})).toBe('inApp')
})
})
describe('reducer NCII qualification', () => {
it('holds at step 2 when the NCII reason is selected', () => {
const state = selectNciiOption()
expect(state.activeStepIndex1).toBe(2)
expect(state.ncii).toEqual({})
})
it('does not gate non-NCII reasons', () => {
const state = reducer(initialState, {
type: 'selectOption',
option: otherOption,
})
expect(state.activeStepIndex1).toBe(3)
expect(state.ncii).toBeUndefined()
})
it('holds at step 2 for the depicted person (external form)', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: true,
})
expect(getNciiQualificationOutcome(state.ncii)).toBe('externalForm')
expect(state.activeStepIndex1).toBe(2)
})
it('advances to step 3 when not the depicted person', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: false,
})
expect(state.activeStepIndex1).toBe(3)
})
it('does not advance past a pending question when a labeler is auto-selected', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'selectLabeler',
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
})
expect(state.activeStepIndex1).toBe(2)
})
it('skips to step 4 when the answer resolves after labeler auto-selection', () => {
let state = selectNciiOption()
state = reducer(state, {
type: 'selectLabeler',
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
})
state = reducer(state, {
type: 'answerNciiQuestion',
question: 'isDepicted',
answer: false,
})
expect(state.activeStepIndex1).toBe(4)
})
it('clears NCII state when the reason or category is cleared', () => {
const state = selectNciiOption()
expect(reducer(state, {type: 'clearOption'}).ncii).toBeUndefined()
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
})
})
@@ -1,4 +1,7 @@
import {type AppBskyLabelerDefs} from '@atproto/api'
import {
type AppBskyLabelerDefs,
ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api'
import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const'
import {
@@ -6,6 +9,10 @@ import {
type ReportOption,
} from '#/components/moderation/ReportDialog/utils/useReportOptions'
export type NciiQualification = {
isDepicted?: boolean
}
export type ReportState = {
selectedCategory?: ReportCategoryConfig
selectedOption?: ReportOption
@@ -14,6 +21,26 @@ export type ReportState = {
detailsOpen: boolean
activeStepIndex1: number
error?: string
/**
* Present while the selected reason is NCII. Tracks the answer to the
* qualifying question that determines whether the report should go through
* the external NCII report form instead of in-app submission.
*/
ncii?: NciiQualification
}
/**
* Resolves the NCII qualifying question into an outcome. The depicted person
* (or their authorized representative) is directed to the external NCII
* report form; everyone else proceeds with the normal in-app submission.
*/
export function getNciiQualificationOutcome(
ncii?: NciiQualification,
): 'pending' | 'externalForm' | 'inApp' | undefined {
if (!ncii) return undefined
if (ncii.isDepicted === true) return 'externalForm'
if (ncii.isDepicted === false) return 'inApp'
return 'pending'
}
export type ReportAction =
@@ -32,6 +59,11 @@ export type ReportAction =
| {
type: 'clearOption'
}
| {
type: 'answerNciiQuestion'
question: keyof NciiQualification
answer: boolean
}
| {
type: 'selectLabeler'
labeler: AppBskyLabelerDefs.LabelerViewDetailed
@@ -81,14 +113,19 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
selectedLabeler: undefined,
activeStepIndex1: 1,
detailsOpen: false,
ncii: undefined,
}
case 'selectOption':
case 'selectOption': {
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
return {
...state,
selectedOption: action.option,
activeStepIndex1: 3,
// NCII reports require answering qualifying questions before moving on
activeStepIndex1: isNcii ? 2 : 3,
detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason),
ncii: isNcii ? {} : undefined,
}
}
case 'clearOption':
return {
...state,
@@ -96,12 +133,33 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
selectedLabeler: undefined,
activeStepIndex1: 2,
detailsOpen: false,
ncii: undefined,
}
case 'answerNciiQuestion': {
const ncii = {...state.ncii, [action.question]: action.answer}
return {
...state,
ncii,
activeStepIndex1:
getNciiQualificationOutcome(ncii) === 'inApp'
? state.selectedLabeler
? 4
: 3
: 2,
}
}
case 'selectLabeler':
return {
...state,
selectedLabeler: action.labeler,
activeStepIndex1: 4,
/*
* Labelers may be auto-selected (e.g. chat reports only go to
* Bluesky), so don't advance past pending NCII qualifying questions.
*/
activeStepIndex1:
getNciiQualificationOutcome(state.ncii) === 'inApp' || !state.ncii
? 4
: 2,
detailsOpen: state.selectedOption
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
: false,