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
-11
View File
@@ -244,17 +244,6 @@
"count": 3
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"typescript/no-floating-promises": {
"count": 2
},
"typescript/no-unsafe-enum-comparison": {
"count": 1
},
"typescript/no-unsafe-member-access": {
"count": 1
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
"typescript/no-explicit-any": {
"count": 2
+1
View File
@@ -857,6 +857,7 @@ export type Events = {
reason: string
labeler: string
details: boolean
videoTimestamp: boolean
}
'reportDialog:failure': {}
+3 -1
View File
@@ -62,6 +62,7 @@ export const Input = createInput(TextInput)
export function Outer({
children,
control,
onOpen,
onClose,
nativeOptions,
testID,
@@ -97,9 +98,10 @@ export function Outer({
const open = useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
onOpen?.()
setDialogIsOpen(control.id, true)
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
}, [setDialogIsOpen, control.id, callQueuedCallbacks, onOpen])
// This is the function that we call when we want to dismiss the dialog.
const close = useCallback<DialogControlProps['close']>(cb => {
+3 -1
View File
@@ -52,6 +52,7 @@ const preventDefault = (e: any) => e.preventDefault()
export function Outer({
children,
control,
onOpen,
onClose,
webOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
@@ -61,9 +62,10 @@ export function Outer({
const {setDialogIsOpen} = useDialogStateControlContext()
const open = useCallback(() => {
onOpen?.()
setDialogIsOpen(control.id, true)
setIsOpen(true)
}, [setIsOpen, setDialogIsOpen, control.id])
}, [setIsOpen, setDialogIsOpen, control.id, onOpen])
const close = useCallback<DialogControlProps['close']>(
cb => {
+1
View File
@@ -60,6 +60,7 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = {
control: DialogControlProps
onOpen?: () => void
onClose?: () => void
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
webOptions?: {
@@ -2,8 +2,7 @@ import {useImperativeHandle, useRef, useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyEmbedVideo} from '@atproto/api'
import {BlueskyVideoView} from '@bsky.app/video'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences'
@@ -16,6 +15,7 @@ import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {KeepAwake} from '#/components/KeepAwake'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {GifPresentationControls} from '../GifPresentationControls'
import {TimeIndicator} from './TimeIndicator'
@@ -39,11 +39,13 @@ export function VideoEmbedInnerNative({
*/
onError?: (error: string) => void
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const videoRef = useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled()
const isWithinMessage = useIsWithinMessage()
const [muted, setMuted] = useVideoMuteState()
const reportDialogMetadata = useReportDialogMetadataContext()
const maxTimeRemainingSeconds = useRef(0)
const [isPlaying, setIsPlaying] = useState(false)
const [timeRemaining, setTimeRemaining] = useState(0)
@@ -84,16 +86,30 @@ export function VideoEmbedInnerNative({
setIsPlaying(e.nativeEvent.status === 'playing')
}}
onTimeRemainingChange={e => {
setTimeRemaining(e.nativeEvent.timeRemaining)
const {timeRemaining} = e.nativeEvent
setTimeRemaining(timeRemaining)
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(timeRemaining) &&
timeRemaining >= 0
) {
maxTimeRemainingSeconds.current = Math.max(
maxTimeRemainingSeconds.current,
timeRemaining,
)
reportDialogMetadata.current.videoTimestampSeconds = Math.max(
0,
maxTimeRemainingSeconds.current - timeRemaining,
)
}
}}
onError={e => {
onError?.(e.nativeEvent.error)
setError(e.nativeEvent.error)
}}
ref={videoRef}
accessibilityLabel={
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
}
accessibilityLabel={embed.alt ? l`Video: ${embed.alt}` : l`Video`}
accessibilityHint=""
/>
{isGif ? (
@@ -144,7 +160,7 @@ function VideoPresentationControls({
timeRemaining: number
isPlaying: boolean
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const [muted] = useVideoMuteState()
@@ -159,14 +175,14 @@ function VideoPresentationControls({
<Pressable
onPress={enterFullscreen}
style={a.flex_1}
accessibilityLabel={_(msg`Video`)}
accessibilityHint={_(msg`Enters full screen`)}
accessibilityLabel={l`Video`}
accessibilityHint={l`Enters full screen`}
accessibilityRole="button"
/>
<ControlButton
onPress={togglePlayback}
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
accessibilityHint={_(msg`Plays or pauses the video`)}
label={isPlaying ? l`Pause` : l`Play`}
accessibilityHint={l`Plays or pauses the video`}
style={{left: 6}}>
{isPlaying ? (
<PauseIcon width={13} fill={t.palette.white} />
@@ -175,15 +191,14 @@ function VideoPresentationControls({
)}
</ControlButton>
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
<ControlButton
onPress={toggleMuted}
label={
muted
? _(msg({message: `Unmute`, context: 'video'}))
: _(msg({message: `Mute`, context: 'video'}))
? l({message: `Unmute`, context: 'video'})
: l({message: `Mute`, context: 'video'})
}
accessibilityHint={_(msg`Toggles the sound`)}
accessibilityHint={l`Toggles the sound`}
style={{right: 6}}>
{muted ? (
<MuteIcon width={13} fill={t.palette.white} />
@@ -1,13 +1,13 @@
import {useCallback, useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import * as BandwidthEstimate from './bandwidth-estimate'
import {
HLSFatalError,
@@ -36,9 +36,10 @@ export function VideoEmbedInnerWeb({
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId()
const {_} = useLingui()
const {t: l} = useLingui()
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
const reportDialogMetadata = useReportDialogMetadataContext()
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -63,7 +64,7 @@ export function VideoEmbedInnerWeb({
return (
<View
style={[a.flex_1, a.rounded_md, a.overflow_hidden]}
accessibilityLabel={_(msg`Embedded video player`)}
accessibilityLabel={l`Embedded video player`}
accessibilityHint="">
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
@@ -76,7 +77,16 @@ export function VideoEmbedInnerWeb({
muted={embed.presentation === 'gif' || !focused}
aria-labelledby={embed.alt ? figId : undefined}
onTimeUpdate={e => {
lastKnownTime.current = e.currentTarget.currentTime
const currentTime = e.currentTarget.currentTime
lastKnownTime.current = currentTime
if (
!isGif &&
reportDialogMetadata &&
Number.isFinite(currentTime) &&
currentTime >= 0
) {
reportDialogMetadata.current.videoTimestampSeconds = currentTime
}
}}
loop={loop}
/>
@@ -141,9 +151,10 @@ type CachedPromise<T> = Promise<T> & {value: undefined | T}
const promiseForHls = import(
// @ts-ignore
'hls.js/dist/hls.min'
// oxlint-disable-next-line typescript/no-unsafe-member-access
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
promiseForHls.value = undefined
promiseForHls.then(Hls => {
void promiseForHls.then(Hls => {
promiseForHls.value = Hls
})
@@ -166,7 +177,7 @@ function useHLS({
useEffect(() => {
if (!Hls) {
setHlsLoading(true)
promiseForHls.then(loadedHls => {
void promiseForHls.then(loadedHls => {
setHls(() => loadedHls)
setHlsLoading(false)
})
@@ -303,7 +314,7 @@ function useHLS({
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
if (
data.details === 'manifestLoadError' &&
(data.details as string) === 'manifestLoadError' &&
data.response?.code === 404
) {
setError(new VideoNotFoundError())
@@ -3,11 +3,11 @@ import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {formatTime} from '#/lib/media/video/formatTime'
import {clamp} from '#/lib/numbers'
import {atoms as a, useTheme, web} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {IS_WEB_FIREFOX, IS_WEB_TOUCH_DEVICE} from '#/env'
import {formatTime} from './utils'
export function Scrubber({
duration,
@@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {formatTime} from '#/lib/media/video/formatTime'
import {clamp} from '#/lib/numbers'
import {
useAutoplayDisabled,
@@ -31,7 +32,7 @@ import {GifPresentationControls} from '../../GifPresentationControls'
import {TimeIndicator} from '../TimeIndicator'
import {ControlButton} from './ControlButton'
import {Scrubber} from './Scrubber'
import {formatTime, useVideoElement} from './utils'
import {useVideoElement} from './utils'
import {type ControlsProps} from './VideoControls.shared'
import {VolumeControl} from './VolumeControl'
@@ -238,16 +238,3 @@ export function useVideoElement(ref: RefObject<HTMLVideoElement | null>) {
canPlay,
}
}
export function formatTime(time: number) {
if (isNaN(time)) {
return '--'
}
time = Math.round(time)
const minutes = Math.floor(time / 60)
const seconds = String(time % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
+3 -2
View File
@@ -23,6 +23,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {StandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed'
import {isStandardSiteEmbed} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {RichText} from '#/components/RichText'
@@ -311,7 +312,7 @@ export function QuoteEmbed({
} = useInteractionState()
const contents = (
<>
<ReportDialogMetadataContext.Provider key={quote.uri}>
<PostMeta
author={quote.author}
moderation={moderation}
@@ -350,7 +351,7 @@ export function QuoteEmbed({
post={quote}
/>
)}
</>
</ReportDialogMetadataContext.Provider>
)
return (
+1
View File
@@ -82,6 +82,7 @@ export type ItemProps = ViewStyleProp & {
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
hitSlop?: PressableProps['hitSlop']
highlightRow?: boolean
testID?: string
}
export function useItemContext() {
@@ -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,
}
}
}
@@ -0,0 +1,16 @@
import {formatTime} from '../formatTime'
describe('formatTime', () => {
it('formats seconds as minutes and seconds', () => {
expect(formatTime(0)).toBe('0:00')
expect(formatTime(65)).toBe('1:05')
})
it('rounds fractional seconds', () => {
expect(formatTime(12.5)).toBe('0:13')
})
it('handles an unknown duration', () => {
expect(formatTime(NaN)).toBe('--')
})
})
+16
View File
@@ -0,0 +1,16 @@
/**
* Formats a duration in seconds as minutes and seconds for video controls and
* related UI.
*/
export function formatTime(time: number) {
if (isNaN(time)) {
return '--'
}
time = Math.round(time)
const minutes = Math.floor(time / 60)
const seconds = String(time % 60).padStart(2, '0')
return `${minutes}:${seconds}`
}
@@ -48,6 +48,7 @@ import {GalleryBleed} from '#/components/images/Gallery'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {TranslatedPost} from '#/components/Post/Translated'
@@ -84,16 +85,16 @@ export function ThreadItemAnchor({
}
return (
<ThreadItemAnchorInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
<ThreadItemAnchorInner
item={item}
isRoot={isRoot}
postShadow={postShadow}
onPostSuccess={onPostSuccess}
threadgateRecord={threadgateRecord}
postSource={postSource}
/>
</ReportDialogMetadataContext.Provider>
)
}
@@ -42,6 +42,7 @@ import {
} from '#/components/images/Gallery'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
@@ -76,13 +77,15 @@ export function ThreadItemPost({
}
return (
<ThreadItemPostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
<ThreadItemPostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
</ReportDialogMetadataContext.Provider>
)
}
@@ -39,6 +39,7 @@ import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Tra
import {GalleryBleed} from '#/components/images/Gallery'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {type AppModerationCause} from '#/components/Pills'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
@@ -75,15 +76,15 @@ export function ThreadItemTreePost({
}
return (
<ThreadItemTreePostInner
// Safeguard from clobbering per-post state below:
key={postShadow.uri}
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
<ReportDialogMetadataContext.Provider key={postShadow.uri}>
<ThreadItemTreePostInner
item={item}
postShadow={postShadow}
threadgateRecord={threadgateRecord}
overrides={overrides}
onPostSuccess={onPostSuccess}
/>
</ReportDialogMetadataContext.Provider>
)
}
@@ -22,8 +22,8 @@ import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
import {useEventListener} from 'expo'
import {type VideoPlayer} from 'expo-video'
import {formatTime} from '#/lib/media/video/formatTime'
import {atoms as a, tokens} from '#/alf'
import {formatTime} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils'
import {Text} from '#/components/Typography'
// magic number that is roughly the min height of the write reply button
+24 -2
View File
@@ -97,6 +97,7 @@ import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import * as Hider from '#/components/moderation/Hider'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {PostControls} from '#/components/PostControls'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
@@ -112,7 +113,7 @@ function createThreeVideoPlayers(
const eventInterval = platform({
ios: 0.2,
android: 0.5,
default: 0,
default: 0.2,
})
const p1 = createVideoPlayer(sources?.[0] ?? '')
p1.loop = true
@@ -528,7 +529,7 @@ let VideoItem = ({
// we can't distinguish between them
const shouldRenderVideo = active || ios(adjacent)
return (
const content = (
<View style={[a.relative, {height, width}]}>
{postShadow === POST_TOMBSTONE ? (
<View
@@ -573,6 +574,12 @@ let VideoItem = ({
)}
</View>
)
return (
<ReportDialogMetadataContext.Provider key={post.uri}>
{content}
</ReportDialogMetadataContext.Provider>
)
}
VideoItem = memo(VideoItem)
@@ -587,6 +594,8 @@ function VideoItemInner({
}) {
const {bottom} = useSafeAreaInsets()
const [isReady, setIsReady] = useState(!IS_ANDROID)
const reportDialogMetadata =
ReportDialogMetadataContext.useReportDialogMetadataContext()
usePlaybackTelemetry({player, active, playlist: embed.playlist})
@@ -594,6 +603,19 @@ function VideoItemInner({
if (IS_ANDROID && !isReady && evt.currentTime >= 0.05) {
setIsReady(true)
}
/*
* Players are pooled and reassigned as the user swipes. Only trust the item
* that's actually on screen.
*/
if (
active &&
embed.presentation !== 'gif' &&
reportDialogMetadata &&
Number.isFinite(evt.currentTime) &&
evt.currentTime >= 0
) {
reportDialogMetadata.current.videoTimestampSeconds = evt.currentTime
}
})
return (
+13 -10
View File
@@ -31,6 +31,7 @@ import {
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {Embed, PostEmbedViewContext} from '#/components/Post/Embed'
import {PostRepliedTo} from '#/components/Post/PostRepliedTo'
import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton'
@@ -81,16 +82,18 @@ export function Post({
}
if (record && richText && moderation) {
return (
<PostInner
post={postShadowed}
record={record}
richText={richText}
moderation={moderation}
showReplyLine={showReplyLine}
hideTopBorder={hideTopBorder}
style={style}
onBeforePress={onBeforePress}
/>
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
<PostInner
post={postShadowed}
record={record}
richText={richText}
moderation={moderation}
showReplyLine={showReplyLine}
hideTopBorder={hideTopBorder}
style={style}
onBeforePress={onBeforePress}
/>
</ReportDialogMetadataContext.Provider>
)
}
return null
+22 -21
View File
@@ -40,6 +40,7 @@ import {
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import * as ReportDialogMetadataContext from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
import {type AppModerationCause} from '#/components/Pills'
import {Embed} from '#/components/Post/Embed'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
@@ -112,27 +113,27 @@ export function PostFeedItem({
}
if (richText && moderation) {
return (
<FeedItemInner
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed}
record={record}
reason={reason}
feedContext={feedContext}
reqId={reqId}
richText={richText}
parentAuthor={parentAuthor}
showReplyTo={showReplyTo}
moderation={moderation}
isThreadChild={isThreadChild}
isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
rootPost={rootPost}
onShowLess={onShowLess}
/>
<ReportDialogMetadataContext.Provider key={postShadowed.uri}>
<FeedItemInner
post={postShadowed}
record={record}
reason={reason}
feedContext={feedContext}
reqId={reqId}
richText={richText}
parentAuthor={parentAuthor}
showReplyTo={showReplyTo}
moderation={moderation}
isThreadChild={isThreadChild}
isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
rootPost={rootPost}
onShowLess={onShowLess}
/>
</ReportDialogMetadataContext.Provider>
)
}
return null