Add timestamp field to video report dialog (#11339)

This commit is contained in:
DS Boyce
2026-08-06 12:11:05 -07:00
committed by GitHub
parent 724013df1b
commit 1c349ad7da
27 changed files with 402 additions and 119 deletions
@@ -0,0 +1,24 @@
import {createContext, useContext, useRef} from 'react'
export type ReportDialogMetadata = {
videoTimestampSeconds?: number
}
export type ReportDialogMetadataRef = React.RefObject<ReportDialogMetadata>
const Context = createContext<ReportDialogMetadataRef | null>(null)
Context.displayName = 'ReportDialogMetadataContext'
/**
* Scopes report metadata to a rendered subject. The mutable ref lets media
* events update metadata without rerendering the post on every playback tick.
*/
export function Provider({children}: React.PropsWithChildren) {
const metadata = useRef<ReportDialogMetadata>({})
return <Context.Provider value={metadata}>{children}</Context.Provider>
}
export function useReportDialogMetadataContext() {
return useContext(Context)
}
@@ -1,5 +1,6 @@
import {
type $Typed,
BSKY_LABELER_DID,
type ChatBskyConvoDefs,
type ComAtprotoModerationCreateReport,
} from '@atproto/api'
@@ -9,7 +10,7 @@ import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {NEW_TO_OLD_REASONS_MAP} from './const'
import {NEW_TO_OLD_REASONS_MAP, REPORT_MOD_TOOL_NAME} from './const'
import {type ReportState} from './state'
import {type ParsedReportSubject} from './types'
@@ -21,9 +22,15 @@ export function useSubmitReportMutation() {
async mutationFn({
subject,
state,
videoTimestampSeconds,
}: {
subject: ParsedReportSubject
state: ReportState
/**
* How far the viewer watched when the dialog opened, if the subject is a
* post with a video.
*/
videoTimestampSeconds?: number
}) {
if (!state.selectedOption) {
throw new Error(_(msg`Please select a reason for this report`))
@@ -115,6 +122,21 @@ export function useSubmitReportMutation() {
}
}
const modToolMeta =
state.includeVideoTimestamp &&
videoTimestampSeconds != null &&
subject.type === 'post' &&
labeler.creator.did === BSKY_LABELER_DID
? {videoTimestampSeconds}
: undefined
if (modToolMeta) {
report.modTool = {
name: REPORT_MOD_TOOL_NAME,
meta: modToolMeta,
}
}
if (__DEV__) {
logger.info('Submitting report (dry run)', {
labeler: {
@@ -1,14 +1,27 @@
import {applicationId} from 'expo-application'
import {
ComAtprotoModerationDefs as RootReportDefs,
ToolsOzoneReportDefs as OzoneReportDefs,
} from '@atproto/api'
import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/types'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
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'
/**
* Identifies this client as the source of a report.
*/
export const REPORT_MOD_TOOL_NAME = IS_IOS
? `bsky-app/ios/${applicationId}`
: IS_ANDROID
? `bsky-app/android/${applicationId}`
: IS_WEB
? `bsky-web/${window.location.hostname}`
: 'bsky' // Should never occur
export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {}
/**
@@ -11,6 +11,7 @@ import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {wait} from '#/lib/async/wait'
import {formatTime} from '#/lib/media/video/formatTime'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {useCallOnce} from '#/lib/once'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -22,6 +23,7 @@ import * as Admonition from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import * as Toggle from '#/components/forms/Toggle'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
import {
@@ -46,6 +48,7 @@ import {
} from './const'
import {useCopyForSubject} from './copy'
import {classifyReportError} from './errors'
import {useReportDialogMetadataContext} from './ReportDialogMetadataContext'
import {
getNciiQualificationOutcome,
initialState,
@@ -79,19 +82,47 @@ export function ReportDialog(
},
) {
const ax = useAnalytics()
const reportDialogMetadata = useReportDialogMetadataContext()
const subject = useMemo(
() => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject],
)
const [presentation, setPresentation] = useState<{
openCount: number
videoTimestampSeconds?: number
}>({openCount: 0})
const onOpen = useCallback(() => {
const seconds =
subject?.type === 'post' && subject.attributes.video
? reportDialogMetadata?.current.videoTimestampSeconds
: undefined
setPresentation(current => ({
openCount: current.openCount + 1,
// Values below one second indicate that the video was never meaningfully
// played, so avoid offering to attach a noisy "0:00" timestamp.
videoTimestampSeconds:
seconds !== undefined && seconds >= 1 ? Math.floor(seconds) : undefined,
}))
}, [reportDialogMetadata, subject])
const propsOnClose = props.onClose
const onClose = useCallback(() => {
ax.metric('reportDialog:close', {})
propsOnClose?.()
}, [ax, propsOnClose])
return (
<Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Outer control={props.control} onOpen={onOpen} onClose={onClose}>
<Dialog.Handle />
{subject ? <Inner {...props} subject={subject} /> : <Invalid />}
{subject ? (
<Inner
key={presentation.openCount}
{...props}
subject={subject}
videoTimestampSeconds={presentation.videoTimestampSeconds}
/>
) : (
<Invalid />
)}
</Dialog.Outer>
)
}
@@ -118,7 +149,11 @@ function Invalid() {
)
}
function Inner(props: ReportDialogProps) {
function Inner(
props: ReportDialogProps & {
videoTimestampSeconds?: number
},
) {
const ax = useAnalytics()
const logger = ax.logger.useChild(ax.logger.Context.ReportDialog)
const t = useTheme()
@@ -142,6 +177,8 @@ function Inner(props: ReportDialogProps) {
const [isPending, setIsPending] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const {videoTimestampSeconds} = props
// some reasons ONLY go to Bluesky
const isBskyOnlyReason = state?.selectedOption?.reason
? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason)
@@ -230,6 +267,7 @@ function Inner(props: ReportDialogProps) {
submitReport({
subject: props.subject,
state,
videoTimestampSeconds,
}),
)
setIsSuccess(true)
@@ -237,6 +275,7 @@ function Inner(props: ReportDialogProps) {
reason: state.selectedOption?.reason ?? '',
labeler: state.selectedLabeler?.creator.handle ?? '',
details: !!state.details,
videoTimestamp: state.includeVideoTimestamp,
})
// give time for user feedback
setTimeout(() => {
@@ -282,7 +321,7 @@ function Inner(props: ReportDialogProps) {
} finally {
setIsPending(false)
}
}, [logger, submitReport, props, state, ax, l])
}, [logger, submitReport, props, state, ax, l, videoTimestampSeconds])
useCallOnce(() => {
ax.metric('reportDialog:open', {
@@ -602,6 +641,18 @@ function Inner(props: ReportDialogProps) {
</View>
)}
</View>
{videoTimestampSeconds !== undefined &&
state.selectedLabeler?.creator.did === BSKY_LABELER_DID && (
<IncludeVideoTimestampToggle
seconds={videoTimestampSeconds}
selected={state.includeVideoTimestamp}
onChange={include => {
dispatch({type: 'setIncludeVideoTimestamp', include})
}}
/>
)}
<Button
testID="report:submit"
label={l`Submit report`}
@@ -632,6 +683,42 @@ function Inner(props: ReportDialogProps) {
)
}
/**
* Opt-in for attaching how far the viewer had watched to a video report. Only
* rendered once we have a position and the report is going to Bluesky.
*/
function IncludeVideoTimestampToggle({
seconds,
selected,
onChange,
}: {
seconds: number
selected: boolean
onChange: (selected: boolean) => void
}) {
const {t: l} = useLingui()
const videoTimestamp = formatTime(seconds)
const label = l({
message: `Include video timestamp (${videoTimestamp})`,
comment:
'Checkbox shown when reporting a video. The value is the current playback position, formatted as minutes:seconds.',
})
return (
<Toggle.Item
testID="report:includeVideoTimestamp"
name="includeVideoTimestamp"
type="checkbox"
label={label}
value={selected}
onChange={onChange}>
<Toggle.Checkbox />
<Toggle.LabelText style={[a.flex_1, a.font_normal, a.leading_snug]}>
{label}
</Toggle.LabelText>
</Toggle.Item>
)
}
function ActionOnce({
check,
callback,
@@ -108,3 +108,43 @@ describe('reducer NCII qualification', () => {
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
})
})
describe('reducer video timestamp', () => {
const labeler = {} as AppBskyLabelerDefs.LabelerViewDetailed
const opted: ReportState = {...initialState, includeVideoTimestamp: true}
it('is off by default', () => {
expect(initialState.includeVideoTimestamp).toBe(false)
})
it('toggles on and back off', () => {
let state = reducer(initialState, {
type: 'setIncludeVideoTimestamp',
include: true,
})
expect(state.includeVideoTimestamp).toBe(true)
state = reducer(state, {type: 'setIncludeVideoTimestamp', include: false})
expect(state.includeVideoTimestamp).toBe(false)
})
it('clears when the moderation service is cleared', () => {
expect(reducer(opted, {type: 'clearLabeler'}).includeVideoTimestamp).toBe(
false,
)
})
it('clears when a moderation service is selected', () => {
expect(
reducer(opted, {type: 'selectLabeler', labeler}).includeVideoTimestamp,
).toBe(false)
})
it('clears when the reason or category is cleared', () => {
expect(reducer(opted, {type: 'clearOption'}).includeVideoTimestamp).toBe(
false,
)
expect(reducer(opted, {type: 'clearCategory'}).includeVideoTimestamp).toBe(
false,
)
})
})
@@ -21,6 +21,11 @@ export type ReportState = {
detailsOpen: boolean
activeStepIndex1: number
error?: string
/**
* Whether to attach how far the viewer had watched to the report. Only
* offered for posts with a video.
*/
includeVideoTimestamp: boolean
/**
* Present while the selected reason is NCII. Tracks the answer to the
* qualifying question that determines whether the report should go through
@@ -85,6 +90,10 @@ export type ReportAction =
| {
type: 'showDetails'
}
| {
type: 'setIncludeVideoTimestamp'
include: boolean
}
export const initialState: ReportState = {
selectedCategory: undefined,
@@ -93,6 +102,7 @@ export const initialState: ReportState = {
details: undefined,
detailsOpen: false,
activeStepIndex1: 1,
includeVideoTimestamp: false,
}
export function reducer(state: ReportState, action: ReportAction): ReportState {
@@ -114,6 +124,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
activeStepIndex1: 1,
detailsOpen: false,
ncii: undefined,
includeVideoTimestamp: false,
}
case 'selectOption': {
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
@@ -134,6 +145,7 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
activeStepIndex1: 2,
detailsOpen: false,
ncii: undefined,
includeVideoTimestamp: false,
}
case 'answerNciiQuestion': {
const ncii = {...state.ncii, [action.question]: action.answer}
@@ -163,12 +175,18 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
detailsOpen: state.selectedOption
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
: false,
/*
* Picking a service is a fresh consent decision - the opt-in is scoped
* to Bluesky, so it must not survive a switch to another labeler.
*/
includeVideoTimestamp: false,
}
case 'clearLabeler':
return {
...state,
selectedLabeler: undefined,
activeStepIndex1: 3,
includeVideoTimestamp: false,
}
case 'setDetails':
return {
@@ -190,5 +208,10 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
...state,
detailsOpen: true,
}
case 'setIncludeVideoTimestamp':
return {
...state,
includeVideoTimestamp: action.include,
}
}
}