From 68b62607b70ec0f99ef2f7bbad5c36b620cdc962 Mon Sep 17 00:00:00 2001 From: Chowdhury Foysal Ahamed Date: Tue, 14 Jul 2026 20:49:35 +0200 Subject: [PATCH] Add additional questions before routing to TIDA intake form --- .../moderation/ReportDialog/const.ts | 2 +- .../moderation/ReportDialog/index.tsx | 212 +++++++++++++----- .../moderation/ReportDialog/state.test.ts | 167 ++++++++++++++ .../moderation/ReportDialog/state.ts | 75 ++++++- 4 files changed, 400 insertions(+), 56 deletions(-) create mode 100644 src/components/moderation/ReportDialog/state.test.ts diff --git a/src/components/moderation/ReportDialog/const.ts b/src/components/moderation/ReportDialog/const.ts index 02fd594879..8f5f16db9f 100644 --- a/src/components/moderation/ReportDialog/const.ts +++ b/src/components/moderation/ReportDialog/const.ts @@ -7,7 +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 TAKE_IT_DOWN_ACT_FORM = 'https://forms.bsky.app/f/ncii' +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 338570d71c..6854c3bd41 100644 --- a/src/components/moderation/ReportDialog/index.tsx +++ b/src/components/moderation/ReportDialog/index.tsx @@ -40,12 +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, - TAKE_IT_DOWN_ACT_FORM, } 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 { @@ -382,23 +388,28 @@ function Inner(props: ReportDialogProps) { activeIndex1={state.activeStepIndex1} /> {state.selectedOption ? ( - - - + <> + + + + + - - + {state.ncii && ( + + )} + ) : state.selectedCategory ? ( {getCategory(state.selectedCategory.key).options.map(o => ( @@ -410,39 +421,6 @@ function Inner(props: ReportDialogProps) { }} /> ))} - - {state.selectedCategory.key === 'sexualAdultContent' && ( - - {({hovered, pressed}) => ( - - - - Need this report to be reviewed under the “Take it - down act”? - - - - - )} - - )} ) : null} @@ -814,6 +792,138 @@ function OptionCard({ ) } +/** + * Qualifying questions shown when the NCII reason is selected. US residents + * reporting imagery of themselves (or on behalf of the person depicted) are + * 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: 'residesInUS', + answer, + }) + }} + /> + {ncii.residesInUS === true && ( + { + 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..eed7602c5c --- /dev/null +++ b/src/components/moderation/ReportDialog/state.test.ts @@ -0,0 +1,167 @@ +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 questions are answered', () => { + expect(getNciiQualificationOutcome({})).toBe('pending') + expect(getNciiQualificationOutcome({residesInUS: true})).toBe('pending') + }) + + it('directs US residents depicted in the imagery to the external form', () => { + expect( + getNciiQualificationOutcome({residesInUS: true, isDepicted: true}), + ).toBe('externalForm') + }) + + it('directs everyone else to in-app submission', () => { + expect(getNciiQualificationOutcome({residesInUS: false})).toBe('inApp') + expect( + getNciiQualificationOutcome({residesInUS: true, 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('advances to step 3 for non-US residents', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: false, + }) + expect(state.activeStepIndex1).toBe(3) + }) + + it('holds at step 2 for US residents until the second answer', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: true, + }) + expect(state.activeStepIndex1).toBe(2) + }) + + it('holds at step 2 for depicted US residents (external form)', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: true, + }) + 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 for non-depicted US residents', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: true, + }) + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'isDepicted', + answer: false, + }) + expect(state.activeStepIndex1).toBe(3) + }) + + it('resets the second answer when the first changes', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: true, + }) + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'isDepicted', + answer: true, + }) + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: true, + }) + expect(state.ncii?.isDepicted).toBeUndefined() + expect(state.activeStepIndex1).toBe(2) + }) + + it('does not advance past pending questions 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 answers resolve after labeler auto-selection', () => { + let state = selectNciiOption() + state = reducer(state, { + type: 'selectLabeler', + labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed, + }) + state = reducer(state, { + type: 'answerNciiQuestion', + question: 'residesInUS', + answer: false, + }) + expect(state.activeStepIndex1).toBe(4) + }) + + it('clears NCII state when the reason or category is cleared', () => { + let 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..402ac9026c 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,11 @@ import { type ReportOption, } from '#/components/moderation/ReportDialog/utils/useReportOptions' +export type NciiQualification = { + residesInUS?: boolean + isDepicted?: boolean +} + export type ReportState = { selectedCategory?: ReportCategoryConfig selectedOption?: ReportOption @@ -14,6 +22,30 @@ export type ReportState = { detailsOpen: boolean activeStepIndex1: number error?: string + /** + * Present while the selected reason is NCII. Tracks answers to the + * qualifying questions that determine whether the report should go through + * the external NCII report form instead of in-app submission. + */ + ncii?: NciiQualification +} + +/** + * Resolves the NCII qualifying questions into an outcome. US residents who + * are the depicted person (or their authorized representative) are 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.residesInUS === false) return 'inApp' + if (ncii.residesInUS === true) { + if (ncii.isDepicted === true) return 'externalForm' + if (ncii.isDepicted === false) return 'inApp' + } + return 'pending' } export type ReportAction = @@ -32,6 +64,11 @@ export type ReportAction = | { type: 'clearOption' } + | { + type: 'answerNciiQuestion' + question: keyof NciiQualification + answer: boolean + } | { type: 'selectLabeler' labeler: AppBskyLabelerDefs.LabelerViewDetailed @@ -81,14 +118,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 +138,37 @@ 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} + // the second question only applies to US residents + if (action.question === 'residesInUS') { + ncii.isDepicted = undefined + } + 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,