Add timestamp field to video report dialog (#11339)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user