Add TIDA form URL for adult content report (#11150)
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
@@ -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> = {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user