diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index b516ec0006..8f5f16db9f 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -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 = {} diff --git a/src/components/moderation/ReportDialog/index.tsx b/src/components/moderation/ReportDialog/index.tsx index d1d3aa5584..dfb3e781a9 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -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 ? ( - - - + <> + + + + + - - + {state.ncii && ( + + )} + ) : state.selectedCategory ? ( {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 +}) { + const t = useTheme() + const {t: l} = useLingui() + const outcome = getNciiQualificationOutcome(ncii) + return ( + + { + dispatch({ + type: 'answerNciiQuestion', + question: 'isDepicted', + answer, + }) + }} + /> + {outcome === 'externalForm' && ( + + {({hovered, pressed}) => ( + + + + Please submit your report through the Report non-consensual + intimate imagery (NCII) form. + + + + + )} + + )} + + ) +} + +function YesNoQuestion({ + question, + value, + onAnswer, + testID, +}: { + question: string + value?: boolean + onAnswer: (answer: boolean) => void + testID?: string +}) { + const {t: l} = useLingui() + return ( + + {question} + + + + + + + + + + ) +} + function OptionCardSkeleton() { const t = useTheme() return ( diff --git a/src/components/moderation/ReportDialog/state.test.ts b/src/components/moderation/ReportDialog/state.test.ts new file mode 100644 index 0000000000..6c08fe3d5c --- /dev/null +++ b/src/components/moderation/ReportDialog/state.test.ts @@ -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() + }) +}) diff --git a/src/components/moderation/ReportDialog/state.ts b/src/components/moderation/ReportDialog/state.ts index 7b15e174b2..d86b5e28b9 100644 --- a/src/components/moderation/ReportDialog/state.ts +++ b/src/components/moderation/ReportDialog/state.ts @@ -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,